Writing Append-Only Lineage Manifests to Parquet Jump to heading
A lineage store is only trustworthy if a prior audit record can never change. This guide is the physical write layer beneath Lineage Manifest Generation: it takes a validated Arrow table of lineage events and commits it to a pyarrow >=14 partitioned Parquet dataset so that every existing byte stays exactly as it was written. The record contract, deduplication, and retention policy are decided upstream in that stage; here we own one thing — appending immutably. Get this wrong and the whole compliance trail is repudiable; get it right and the store is a legally defensible, append-only history.
The mechanism is not “open the dataset and add rows.” Parquet files are immutable once closed, so append means write a new part file into an existing partition directory, atomically, and prove nothing else moved. Each flush becomes its own part-<uuid>.parquet; the dataset is the union of all part files across all partition directories. That is what makes the store append-only by construction rather than by convention.
Prerequisites checklist Jump to heading
Step 1: Configure the partitioned schema Jump to heading
Pin the Arrow schema and declare the partition keys explicitly. Partitioning on run_id and target_crs gives Hive-style directories (run_id=.../target_crs=.../) that let audit queries prune to a single run without scanning the whole store. Bind the schema to the dataset at configuration time so a drifting field type is caught at write, not at read.
# lineage/parquet_writer.py — python 3.10+, pyarrow >=14
from __future__ import annotations
import pyarrow as pa
import pyarrow.dataset as ds
# Must match the contract compiled in the parent Lineage Manifest stage.
LINEAGE_ARROW_SCHEMA = pa.schema([
("event_id", pa.string()),
("run_id", pa.string()),
("sequence", pa.int64()),
("event_time_utc", pa.timestamp("us", tz="UTC")),
("stage", pa.string()),
("event_type", pa.string()),
("feature_id", pa.string()),
("source_crs", pa.string()),
("target_crs", pa.string()),
("outcome", pa.string()),
("strategy_index", pa.int32()),
("accuracy_m", pa.float64()),
("rejection_reason", pa.string()),
("tool_version", pa.string()),
("audit_tag", pa.string()),
])
PARTITIONING = ds.partitioning(
pa.schema([("run_id", pa.string()), ("target_crs", pa.string())]),
flavor="hive",
)
SCHEMA_VERSION = "1.2.0" # mirrors lineage_schema.yaml; written into every part file's metadata
Stamp the schema version into each file’s key-value metadata. When an auditor opens a part file three years from now, that string tells them exactly which contract the row was written under — the anchor for reproducibility.
Step 2: Write the partition atomically Jump to heading
The write must be atomic: a reader scanning the dataset mid-flush must see either the complete new part file or nothing, never a half-written file. Achieve this with a temp-then-rename move. Write to a hidden temporary path in the same partition directory, then os.replace it to its final part-<uuid>.parquet name — os.replace is atomic on POSIX filesystems, so the file appears in the directory fully formed.
# lineage/parquet_writer.py (continued) — python 3.10+, pyarrow >=14
from __future__ import annotations
import logging
import os
import uuid
from pathlib import Path
import pyarrow as pa
import pyarrow.parquet as pq
logger = logging.getLogger(__name__)
def _partition_dir(root: Path, run_id: str, target_crs: str) -> Path:
# Hive layout: run_id=<...>/target_crs=EPSG:4326/
return root / f"run_id={run_id}" / f"target_crs={target_crs}"
def append_partition(root: Path, table: pa.Table, run_id: str, target_crs: str) -> Path:
"""Atomically append one immutable part file to a partition. Returns its path."""
if table.schema != LINEAGE_ARROW_SCHEMA:
raise ValueError("Table schema does not match the frozen lineage contract.")
part_dir = _partition_dir(root, run_id, target_crs)
part_dir.mkdir(parents=True, exist_ok=True)
final_path = part_dir / f"part-{uuid.uuid4().hex}.parquet"
tmp_path = part_dir / f".{final_path.name}.tmp"
try:
pq.write_table(
table,
tmp_path,
compression="zstd",
# Reproducibility anchor: which contract this file was written under.
metadata_collector=None,
)
# Attach schema version to file-level metadata for auditors.
_tag_schema_version(tmp_path)
os.replace(tmp_path, final_path) # atomic publish
except (OSError, pa.ArrowIOError):
logger.exception("Failed to write lineage partition to %s", final_path)
if tmp_path.exists():
tmp_path.unlink() # never leave a partial temp behind
raise
logger.info("Committed %d rows to %s", table.num_rows, final_path)
return final_path
def _tag_schema_version(path: Path) -> None:
"""Rewrite file metadata to embed the schema version (cheap, footer-only)."""
existing = pq.read_metadata(path)
meta = dict(existing.metadata or {})
meta[b"lineage_schema_version"] = SCHEMA_VERSION.encode()
# Re-open and persist via the table's schema metadata.
table = pq.read_table(path)
table = table.replace_schema_metadata(meta)
pq.write_table(table, path, compression="zstd")
Because each flush lands in a uniquely named file, two concurrent workers writing to the same run_id/target_crs partition never collide — there is no shared file to contend for. This is why the append-only model scales horizontally where a single mutable file would serialize every writer.
Step 3: Validate no prior file was mutated Jump to heading
Append-only is a claim; verify it. Before the write, snapshot the checksums of every existing part file in the target partition. After the write, snapshot again. The pre-existing files must be byte-identical, and exactly one new file must have appeared.
# lineage/immutability.py — python 3.10+
from __future__ import annotations
import hashlib
from pathlib import Path
def _checksums(part_dir: Path) -> dict[str, str]:
"""Map each existing part file name to its SHA-256, ignoring temp files."""
out: dict[str, str] = {}
for p in sorted(part_dir.glob("part-*.parquet")):
out[p.name] = hashlib.sha256(p.read_bytes()).hexdigest()
return out
def assert_append_only(before: dict[str, str], after: dict[str, str]) -> None:
"""Prove the write appended exactly one file and mutated nothing prior."""
# 1. Every file that existed before must be unchanged.
for name, digest in before.items():
if name not in after:
raise AssertionError(f"Prior part file vanished: {name} (store not append-only).")
if after[name] != digest:
raise AssertionError(f"Prior part file mutated: {name} (append-only violated).")
# 2. Exactly one new file must have appeared.
new_files = set(after) - set(before)
if len(new_files) != 1:
raise AssertionError(f"Expected exactly one new part file, found {len(new_files)}: {new_files}")
Run assert_append_only in the write path during development and in CI against a fixture partition. In production, sample it — checksumming every prior file on every flush is O(partition size) and will dominate runtime on large partitions, so verify on a schedule rather than per-write once the invariant is trusted.
Step 4: Log the manifest pointer Jump to heading
A part file that no index knows about is invisible to audits. After each atomic commit, append a pointer record — the file path, row count, schema version, and a content digest — to a lightweight pointer log. This log is the manifest’s table of contents and the fastest way to reconstruct exactly which files a given run produced.
# lineage/pointer_log.py — python 3.10+
from __future__ import annotations
import datetime as dt
import hashlib
import json
from pathlib import Path
def log_manifest_pointer(
pointer_log: Path, part_path: Path, run_id: str, row_count: int, schema_version: str
) -> None:
"""Append one NDJSON pointer describing a committed part file."""
digest = hashlib.sha256(part_path.read_bytes()).hexdigest()
record = {
"run_id": run_id,
"part_path": str(part_path),
"row_count": row_count,
"schema_version": schema_version,
"sha256": digest,
"committed_utc": dt.datetime.now(tz=dt.timezone.utc).isoformat(),
}
with pointer_log.open("a") as fh:
fh.write(json.dumps(record) + "\n")
The sha256 in the pointer log doubles as tamper evidence: re-checksumming a part file and comparing against its logged digest proves the stored history has not been altered since it was committed — precisely the assurance an ISO 19115 or INSPIRE audit asks for.
Verification Jump to heading
Confirm the full write path end to end — do not trust a clean exit alone. Assert the partition layout, the atomic commit, and the immutability invariant together:
# python 3.10+, pyarrow >=14
from pathlib import Path
import pyarrow.dataset as ds
from lineage.parquet_writer import append_partition, LINEAGE_ARROW_SCHEMA, PARTITIONING
from lineage.immutability import _checksums, assert_append_only
root = Path("/tmp/_manifest")
part_dir = root / "run_id=run-2026-07-13" / "target_crs=EPSG:4326"
part_dir.mkdir(parents=True, exist_ok=True)
before = _checksums(part_dir)
path = append_partition(root, sample_table, "run-2026-07-13", "EPSG:4326")
after = _checksums(part_dir)
# 1. The write appended exactly one immutable file, mutating nothing prior.
assert_append_only(before, after)
# 2. The new file is discoverable through the partitioned dataset.
dataset = ds.dataset(root, format="parquet", partitioning=PARTITIONING)
tbl = dataset.to_table(filter=ds.field("run_id") == "run-2026-07-13")
assert tbl.num_rows == sample_table.num_rows, "row count mismatch after append"
# 3. The schema round-trips unchanged.
assert dataset.schema.field("event_time_utc").type == LINEAGE_ARROW_SCHEMA.field("event_time_utc").type
A healthy run logs one Committed N rows line per flush, assert_append_only raises nothing, and a second identical write produces a second file rather than overwriting the first. On the CLI, find _manifest -name 'part-*.parquet' | wc -l should increase by exactly one per commit, and no .tmp files should remain.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
ValueError: Table schema does not match the frozen lineage contract |
Upstream stage drifted a field type, or a nullable column arrived as a different Arrow type | Re-materialize the table against LINEAGE_ARROW_SCHEMA; do not cast silently in the writer |
| Readers occasionally see a truncated file | Writing directly to the final path instead of temp-then-rename | Route every write through os.replace; never pq.write_table straight to part-*.parquet |
assert_append_only reports a prior file mutated |
A retention or compaction job rewrote part files in place | Make compaction write new files and delete old ones as whole units; never edit a part file |
| Thousands of tiny part files, slow scans | One flush per event instead of per batch, or an un-canonicalized target_crs exploding partitions |
Batch events per flush; canonicalize target_crs to EPSG:xxxx before Step 2 |
pa.ArrowInvalid: Schema at index N was different on read |
Two part files in one dataset carry incompatible schemas | Gate schema evolution: only additive, nullable columns; bump SCHEMA_VERSION and backfill readers |
.tmp files accumulating in partitions |
Write crashed after temp write, before os.replace |
The except branch unlinks temps; add a startup sweep for stale .*.tmp files |
Schema evolution is the subtle one. Parquet tolerates reading files with different-but-compatible schemas only when new columns are nullable additions. Never drop, rename, or retype a column in place — that splits the dataset into unreadable halves. Additive changes bump SCHEMA_VERSION, and the version stamped in each file’s metadata lets a reader apply the right interpretation per file.
Related Jump to heading
- Lineage Manifest Generation — the parent stage that defines the record contract, dedup, and retention this write layer serves
- Generating ISO 19115 Lineage Statements Automatically — the reader that renders these part files into catalogue metadata
- Geospatial Compliance Reporting & Audit Trails — the practice area this immutable store underpins
- Error Handling & Retry Logic — backoff wrappers for transient object-store write failures during commit
- Batch Schema Processing Pipelines — batching guidance that keeps part-file counts and partition sizes healthy at scale