chore(integ): check the metadata scenarios against a JSON truth

metadata.py/.ts now return a dict per scenario and print one JSON line
for check_json.py, replacing the byte diff against truth_metadata.txt.
The moto and from_state chatter can no longer break the check, and
before/after are real booleans instead of the TypeScript side printing
Python's True/False spelling to satisfy a shared file.
This commit is contained in:
Zecheng Zhang
2026-08-16 22:25:59 -07:00
parent 5f8fe6f6a1
commit c4485cb067
5 changed files with 94 additions and 66 deletions
+7 -9
View File
@@ -56,7 +56,6 @@ jobs:
- 'integ/targets.json'
- 'integ/runners/**'
- 'integ/resources/chroma/**'
- 'integ/check_lines.sh'
- 'python/mirage/accessor/mongodb.py'
- 'python/mirage/core/mongodb/**'
- 'python/mirage/commands/builtin/mongodb/**'
@@ -107,7 +106,6 @@ jobs:
fuse:
- 'integ/fuse/**'
- 'integ/check_json.py'
- 'integ/check_lines.sh'
- 'integ/package.json'
- 'python/mirage/fuse/**'
- 'python/mirage/workspace/fuse.py'
@@ -152,10 +150,10 @@ jobs:
# Snapshot roundtrip and out-of-band-delete GC only: the per-command
# metadata cases live in integ/unix/meta{,_overlay} now.
- name: Run metadata snapshot/GC scenarios and diff against truth
- name: Run metadata snapshot/GC scenarios and check against truth
run: |
./python/.venv/bin/python integ/metadata.py > /tmp/metadata.out
diff integ/truth_metadata.txt /tmp/metadata.out
./python/.venv/bin/python integ/metadata.py 2>&1 \
| ./python/.venv/bin/python integ/check_json.py integ/truth_metadata.json
- name: Run lancedb integ (embedded, JSON harness)
env:
@@ -287,11 +285,11 @@ jobs:
# Snapshot roundtrip and out-of-band-delete GC only: the per-command
# metadata cases live in integ/unix/meta{,_overlay} now.
- name: Run metadata snapshot/GC scenarios and diff against truth
# check_json.py is stdlib-only, so the runner's preinstalled python3
# serves it; this job sets up no venv of its own.
- name: Run metadata snapshot/GC scenarios and check against truth
working-directory: integ
run: |
pnpm exec tsx metadata.ts > /tmp/ts-metadata.out
diff truth_metadata.txt /tmp/ts-metadata.out
run: pnpm exec tsx metadata.ts 2>&1 | python3 check_json.py truth_metadata.json
- name: Run cross-mount commands (ram -> ram/s3 via MinIO)
working-directory: integ
+46 -28
View File
@@ -19,8 +19,13 @@
# stat paths, but it cannot snapshot a workspace, reload it onto a fresh
# resource, or mutate a backend out of band. Retiring these needs snapshot
# and namespace support in the harness, not another case file.
#
# Emits its result as one JSON line for integ/check_json.py, so the moto
# server's own chatter cannot break the check and the TypeScript twin
# reports `before`/`after` as real booleans rather than Python spellings.
import asyncio
import json
import logging
import os
import shutil
@@ -51,6 +56,11 @@ from mirage.types import ConsistencyPolicy, FileStat, PathSpec # noqa: E402
if _ON_PATH:
sys.path.insert(0, _INTEG_DIR)
# Every value the truth file asserts: text for the overlay attributes and the
# ls row, a real boolean for the GC pair. Mirrors the TypeScript twin's
# Record<string, string | boolean | null>.
MetaValue = str | bool | None
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
BUCKET = "mirage-integ-meta"
CREDS = dict(aws_access_key_id="testing",
@@ -58,26 +68,32 @@ CREDS = dict(aws_access_key_id="testing",
region_name="us-east-1")
def _meta_field(st: FileStat, field: str) -> str:
if field == "mode":
value = oct(st.mode)[2:] if st.mode is not None else "-"
elif field == "uid":
value = str(st.uid) if st.uid is not None else "-"
elif field == "gid":
value = str(st.gid) if st.gid is not None else "-"
else:
def overlay_stat_fields(st: FileStat) -> dict[str, MetaValue]:
"""Render the four overlay attributes as truth-file values.
`uid` and `gid` are `int | str | None` in both languages, so they are
normalized to text rather than left to serialize as whichever the
backend happened to store.
Args:
st (FileStat): the stat of the restored file.
Returns:
dict[str, MetaValue]: the asserted keys for this scenario.
"""
return {
"overlay_snapshot_mode":
oct(st.mode)[2:] if st.mode is not None else None,
"overlay_snapshot_uid": str(st.uid) if st.uid is not None else None,
"overlay_snapshot_gid": str(st.gid) if st.gid is not None else None,
# First 19 chars ("2026-01-02T15:30:00") so the Z vs +00:00 suffix
# never reaches the byte-diffed truth file.
value = st.modified[:19] if st.modified else "-"
return f"{field}={value}"
# never reaches the truth file.
"overlay_snapshot_mtime": st.modified[:19] if st.modified else None,
}
def meta_stat_line(st: FileStat, fields: tuple[str, ...]) -> str:
return " ".join(_meta_field(st, field) for field in fields)
async def run_overlay_snapshot_roundtrip(ws: Workspace,
fresh: S3Resource) -> None:
async def run_overlay_snapshot_roundtrip(
ws: Workspace, fresh: S3Resource) -> dict[str, MetaValue]:
# Overlay attrs live in namespace NODES, so they must survive a
# snapshot even though the s3 resource is rebuilt fresh at load
# (s3 snapshots redact creds and require a resources= override).
@@ -89,13 +105,12 @@ async def run_overlay_snapshot_roundtrip(ws: Workspace,
restored = await Workspace.load(str(snap), resources={"/data": fresh})
st, _ = await restored.dispatch("stat",
PathSpec.from_str_path("/data/f.txt"))
print("=== overlay_snapshot_roundtrip ===")
print(meta_stat_line(st, ("mode", "uid", "gid", "mtime")))
await restored.execute("rm /data/f.txt")
shutil.rmtree(snap.parent)
return overlay_stat_fields(st)
async def run_overlay_orphan_gc(config: S3Config) -> None:
async def run_overlay_orphan_gc(config: S3Config) -> dict[str, MetaValue]:
# A chmod on a slot-less backend (s3) creates an attribute overlay in
# the namespace. When the object is deleted out-of-band (another agent,
# the raw API), the overlay is orphaned. Under ALWAYS, a stat that the
@@ -109,11 +124,10 @@ async def run_overlay_orphan_gc(config: S3Config) -> None:
await mount.execute_op("unlink", "/data/g.txt")
await ws.execute("stat /data/g.txt")
after = ws.namespace.meta_for("/data/g.txt") is not None
print("=== overlay_orphan_gc ===")
print(f"before={before} after={after}")
return {"overlay_orphan_before": before, "overlay_orphan_after": after}
async def run_snapshot_roundtrip() -> None:
async def run_snapshot_roundtrip() -> dict[str, MetaValue]:
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
await ws.execute("echo alpha > /data/f.txt")
await ws.execute("chmod 601 /data/f.txt && chown 500:dev /data/f.txt"
@@ -122,9 +136,9 @@ async def run_snapshot_roundtrip() -> None:
await ws.snapshot(str(snap))
restored = await Workspace.load(str(snap))
result = await restored.execute("ls -l /data")
print("=== snapshot_meta_roundtrip ===")
print((await result.stdout_str()).rstrip())
line = (await result.stdout_str()).rstrip()
shutil.rmtree(snap.parent)
return {"snapshot_ls_line": line}
async def main() -> None:
@@ -140,16 +154,20 @@ async def main() -> None:
aws_access_key_id="testing",
aws_secret_access_key="testing",
path_style=True)
result: dict[str, MetaValue] = {}
try:
boto3.client("s3", endpoint_url=endpoint,
**CREDS).create_bucket(Bucket=bucket)
s3_ws = Workspace({"/data": S3Resource(config)}, mode=MountMode.WRITE)
await run_overlay_snapshot_roundtrip(s3_ws, S3Resource(config))
await run_overlay_orphan_gc(config)
overlay = await run_overlay_snapshot_roundtrip(s3_ws,
S3Resource(config))
result.update(overlay)
result.update(await run_overlay_orphan_gc(config))
finally:
server.stop()
await run_snapshot_roundtrip()
result.update(await run_snapshot_roundtrip())
print(json.dumps(result))
if __name__ == "__main__":
+32 -23
View File
@@ -19,6 +19,11 @@
// stat paths, but it cannot snapshot a workspace, reload it onto a fresh
// resource, or mutate a backend out of band. Retiring these needs snapshot
// and namespace support in the harness, not another case file.
//
// Emits its result as one JSON line for integ/check_json.py, the same truth
// file the Python twin is checked against. That is why `before`/`after` are
// real booleans here: the byte-diffed truth file this replaced forced this
// side to print Python's `True`/`False` spelling.
import { randomUUID } from 'node:crypto'
import { mkdtempSync, rmSync } from 'node:fs'
@@ -52,20 +57,24 @@ function s3ResourceFromEnv(keyPrefix: string): S3Resource {
})
}
// Kept local now that cases.ts is gone: four fields, and the mtime slice keeps
// the Z vs +00:00 suffix out of the byte-diffed truth file.
function metaStatLine(st: FileStat, fields: ReadonlyArray<string>): string {
return fields
.map((field) => {
if (field === 'mode') return `mode=${st.mode !== undefined ? st.mode.toString(8) : '-'}`
if (field === 'uid') return `uid=${st.uid !== undefined ? String(st.uid) : '-'}`
if (field === 'gid') return `gid=${st.gid !== undefined ? String(st.gid) : '-'}`
return `mtime=${st.modified !== undefined ? st.modified.slice(0, 19) : '-'}`
})
.join(' ')
// uid and gid are `number | string | null` in both languages, so they are
// normalized to text rather than left to serialize as whichever the backend
// happened to store. The mtime slice keeps the Z vs +00:00 suffix out of the
// truth file.
function overlayStatFields(st: FileStat): Record<string, string | null> {
return {
overlay_snapshot_mode: st.mode !== undefined && st.mode !== null ? st.mode.toString(8) : null,
overlay_snapshot_uid: st.uid !== undefined && st.uid !== null ? String(st.uid) : null,
overlay_snapshot_gid: st.gid !== undefined && st.gid !== null ? String(st.gid) : null,
overlay_snapshot_mtime:
st.modified !== undefined && st.modified !== null ? st.modified.slice(0, 19) : null,
}
}
async function runOverlaySnapshotRoundtrip(ws: Workspace, fresh: S3Resource): Promise<void> {
async function runOverlaySnapshotRoundtrip(
ws: Workspace,
fresh: S3Resource,
): Promise<Record<string, string | null>> {
// Overlay attrs live in namespace NODES, so they must survive a
// snapshot even though the s3 resource is rebuilt fresh at load
// (s3 snapshots redact creds and require a resource override).
@@ -78,14 +87,13 @@ async function runOverlaySnapshotRoundtrip(ws: Workspace, fresh: S3Resource): Pr
await ws.snapshot(snap)
const restored = await Workspace.load(snap, {}, { '/data': fresh })
const st = (await restored.dispatch('stat', '/data/f.txt')) as FileStat
console.log('=== overlay_snapshot_roundtrip ===')
console.log(metaStatLine(st, ['mode', 'uid', 'gid', 'mtime']))
await restored.execute('rm /data/f.txt')
await restored.close()
rmSync(dir, { recursive: true, force: true })
return overlayStatFields(st)
}
async function runOverlayOrphanGc(keyPrefix: string): Promise<void> {
async function runOverlayOrphanGc(keyPrefix: string): Promise<Record<string, boolean>> {
// A chmod on a slot-less backend (s3) creates an attribute overlay. When
// the object is deleted out-of-band (raw op, another agent), the overlay
// is orphaned. Under ALWAYS, a single-mount shell stat the backend reports
@@ -102,14 +110,13 @@ async function runOverlayOrphanGc(keyPrefix: string): Promise<void> {
await ws.dispatch('unlink', '/data/g.txt')
await ws.execute('stat /data/g.txt')
const after = ws.namespace.metaFor('/data/g.txt') !== null
console.log('=== overlay_orphan_gc ===')
console.log(`before=${before ? 'True' : 'False'} after=${after ? 'True' : 'False'}`)
return { overlay_orphan_before: before, overlay_orphan_after: after }
} finally {
await ws.close()
}
}
async function runSnapshotRoundtrip(): Promise<void> {
async function runSnapshotRoundtrip(): Promise<Record<string, string>> {
const ws = new Workspace({ '/data': new RAMResource() }, { mode: MountMode.WRITE })
await ws.execute('echo alpha > /data/f.txt')
await ws.execute(
@@ -120,11 +127,11 @@ async function runSnapshotRoundtrip(): Promise<void> {
await ws.snapshot(snap)
const restored = await Workspace.load(snap)
const result = await restored.execute('ls -l /data')
console.log('=== snapshot_meta_roundtrip ===')
console.log(new TextDecoder().decode(result.stdout).trimEnd())
const line = new TextDecoder().decode(result.stdout).trimEnd()
await ws.close()
await restored.close()
rmSync(dir, { recursive: true, force: true })
return { snapshot_ls_line: line }
}
async function main(): Promise<void> {
@@ -133,13 +140,15 @@ async function main(): Promise<void> {
{ '/data': s3ResourceFromEnv(prefix) },
{ mode: MountMode.WRITE },
)
const result: Record<string, string | boolean | null> = {}
try {
await runOverlaySnapshotRoundtrip(s3Ws, s3ResourceFromEnv(prefix))
Object.assign(result, await runOverlaySnapshotRoundtrip(s3Ws, s3ResourceFromEnv(prefix)))
} finally {
await s3Ws.close()
}
await runOverlayOrphanGc(`${prefix}gc/`)
await runSnapshotRoundtrip()
Object.assign(result, await runOverlayOrphanGc(`${prefix}gc/`))
Object.assign(result, await runSnapshotRoundtrip())
console.log(JSON.stringify(result))
}
void main()
+9
View File
@@ -0,0 +1,9 @@
{
"overlay_snapshot_mode": "601",
"overlay_snapshot_uid": "500",
"overlay_snapshot_gid": "dev",
"overlay_snapshot_mtime": "2026-01-02T15:30:00",
"overlay_orphan_before": true,
"overlay_orphan_after": false,
"snapshot_ls_line": "-rw------x 1 500 dev 6 Jan 2 15:30 f.txt"
}
-6
View File
@@ -1,6 +0,0 @@
=== overlay_snapshot_roundtrip ===
mode=601 uid=500 gid=dev mtime=2026-01-02T15:30:00
=== overlay_orphan_gc ===
before=True after=False
=== snapshot_meta_roundtrip ===
-rw------x 1 500 dev 6 Jan 2 15:30 f.txt