ADR 006: The run record is correct before it is sold¶
Status: accepted, 0.7.0
Context¶
--lineage leaves a record of every run: what was read, what was written, and
a data hash per output, so ubunye lineage compare can say whether two runs
produced the same data. Before 0.7.0 that hash could not carry the claim:
- it read a 1 percent sample, so most changes were invisible to it;
- on Spark it depended on row order, so a repartition changed it;
- on pandas it failed quietly and recorded the schema hash as the data hash, so any two frames with the same columns "matched";
- runs from
run_taskand the notebook were stored under a different name and folder from CLI runs, and the notebook recorded nothing at all.
Decision¶
One hash, rows-v1, over every row. Each row becomes one canonical line:
a JSON object with columns sorted by name, written exactly as Spark's to_json
writes it (timestamps as UTC text to the microsecond, doubles the Java way, NaN
as "NaN", nulls left out). The line's SHA-256 is cut into two 64-bit numbers
and each is added up over all rows. Adding does not care about order.
| Promise | How |
|---|---|
| Every row counts | All rows are hashed, in the same pass as the row count |
| Row order does not | The per-row hashes are added, and addition is order free |
| Column order does not | Columns are sorted by name first |
| Any change is seen | One different cell changes one line, so the sums |
| Null is not NaN | null is left out; NaN is written "NaN" |
| Timezone does not matter | Timestamps are written in UTC |
| Same on every engine | Spark runs it as one aggregation; pandas builds the identical line |
Types by one set of names. The hash covers the schema too, with every type written by the same name on every engine:
| Spark | Arrow / pandas | Name in the hash |
|---|---|---|
tinyint, smallint, int, bigint |
int8 to int64 |
int8, int16, int32, int64 |
float, double |
float32, float64 |
float32, float64 |
boolean |
bool |
bool |
string, varchar, char |
string, large_string |
string |
binary |
binary |
binary |
date |
date32 |
date |
timestamp / timestamp_ntz |
timestamp with / without a zone |
timestamp / timestamp_ntz |
decimal(p,s) |
decimal128(p,s) |
decimal(p,s) |
array, map, struct |
list, map, struct |
list<...>, map<...,...>, struct<name:type,...> |
A backend whose frames are neither Spark nor pandas is hashed from its port
(collect() and schema), so its schema must give these names. The backend
conformance suite (ubunye.testing.backend_conformance) checks it by requiring
the same hash as the reference.
On Spark the work stays on the cluster: one agg returns three numbers. The
integration tier checks that Spark and pandas give the same hash for the same
table (nested types, NaN, awkward text, a non-UTC session), and that a whole
task run on both engines leaves identical receipts.
Honest failure. If the rows cannot be read, the record has no data hash
and says why (hash_error). It never substitutes the schema hash.
A complete record. Each record also carries the Ubunye version, the
backend, the template variables (dt, dtf, mode), and each output's
hash_method.
One place. The CLI, run_task, run_pipeline and the notebook all store
records under <usecase_dir>/.ubunye/lineage/<usecase>/<package>/<task>/, with
the same identity, so ubunye lineage list finds every run.
Consequences¶
- Hashing every row costs one extra scan of each output. On Spark it runs where the data is; only three numbers come back.
- Records written before 0.7.0 have no
hash_method.lineage comparecalls them "not comparable" with new records rather than "changed", and calls two missing hashes "unknown" rather than "unchanged". - The
sample_fractionsetting is ignored and kept only so old configs load.
Addendum (0.7.0): run record v2, the receipt says why¶
A v1 record could show that two runs wrote different data, not why: the
transform, an input or the machine could each have moved. Version 2
(record_version: 2) adds what answers that:
| Field | What it is |
|---|---|
code_hash |
sha256: of every .py file in the task folder (paths and contents, line endings normalised, caches and hidden folders skipped) |
environment, environment_hash |
Python version and implementation, platform, machine, and the versions of the packages that can change a result (engine, pyspark, delta-spark, pandas, pyarrow, numpy, narwhals, scikit-learn, torch, mlflow) |
inputs[*].data_hash, row_count, schema_hash |
every input hashed exactly like the outputs (rows-v1) |
timings |
one entry per read, transform and write, with seconds; kept when the run fails |
expectations |
every CONFIG.expectations rule checked, passed or not; kept when the run fails |
ubunye lineage compare reports each of these as changed or unchanged, and
names the packages whose versions moved. ubunye lineage trace prints them.
Hashing inputs costs one more scan of each input. It is on by default; a
recorder built with LineageRecorder(hash_inputs=False) skips it for inputs too
large to read twice, and those inputs then show - for their row count.
Monitors receive the new evidence (inputs, expectations, timings) only if
their task_end accepts those arguments (or **kwargs), so monitors written
for 0.7 keep working unchanged. A v1 record loads as record_version: 1 with
the new fields empty.