Hidden _prov attribute for extrinsic provenance at Entry tables - #1555
Conversation
|
CI note: the red integration jobs are infrastructure, not this branch. All eight matrix jobs report 1052 passed, 33 errors, 0 failed. Every one of the 33 errors is a collection error in an object-storage test — The MinIO testcontainer image could not be pulled, so those suites never ran. Nothing in this branch touches object storage, and no test failed. The provenance tests passed in CI as part of that 1052, and the full suite ran clean locally: 679 passed, 14 skipped, 0 failed across MySQL and PostgreSQL. Re-running the jobs once the image is pullable should clear it. |
|
Thanks @dimitri-yatsenko, reviewing, should we address #1553 as well in this PR? |
|
Correction to my CI note above. I said re-running the jobs once the image is pullable should clear it. That was wrong — it will not clear on its own. MinIO deleted Fix is #1559 — switches the fixture to Chainguard's public build, verified with a cold pull: 118 passed across the five object-storage suites. Merge that first and these go green. |
|
The shape of this is right — Entry-only, framework-owned, and capture on by default. That last one especially: defaulting off would have made this another setting nobody turns on, and the Two things will fire on merge, though. Jobs tables get
|
…insert Both from review of #1555. Job tables were getting `_prov`. `is_entry_table` excluded the other tiers' prefixes one at a time -- `_`, `#`, and `__` for parts -- and `~` was not among them, so `~~analysis`, `~jobs` and `~lineage` all passed. The column really landed and every job row carried a payload; on a large queue that is a lot of JSON nobody asked for. It only surfaced after `jobs.refresh()` materialises the table, which is why a plain populate() in the first round of tests missed it. Now matched against `Manual.tier_regexp`. Enumerating what to exclude makes every tier added later an Entry table until someone remembers this function; matching the tier definition inverts that, so a name is an Entry table only if the library says it is. A `source` that json could not render broke every insert. `config.provenance.source` is deployment-supplied and typed `dict[str, Any]`, so a `date` in it raised `TypeError: Object of type date is not JSON serializable` from inside every insert into every Entry table, naming neither provenance nor the setting responsible. Three layers close it: - `serialize` passes `default=str`, so ordinary values a deployment would actually set -- dates, paths -- record correctly rather than failing; - a validator on the field rejects what remains (non-string keys, cycles) at assignment, where the error belongs; - `_attach_provenance` catches and logs, so recording where a row came from can never stop the row being written. That guarantee previously covered only the version call. Also fixes the `datajoint.migrate.add_prov_column` reference in the settings description -- it lives in `deploy`, and that string shows up in config help. Regression tests fail against the previous code: 4 of them, including the integration one that drives a real job queue.
|
@ttngu207 Both blockers fixed in fa51011 — thank you, these were real and I'd missed both. Job tablesConfirmed exactly as you described: Taken your suggestion and gated on Your parametrization case is in, plus Unserializable
|
…T ... SELECT Three follow-ups from review of #1555. The Entry-table test is now inlined at its two call sites rather than wrapped in `provenance.is_entry_table`. The import stays deferred inside the function: user_tables imports table, which imports declare, so a module-scope import is a cycle -- which is what the wrapper had been hiding. `insert(QueryExpression)` builds INSERT ... SELECT and returned before the provenance was attached, so copied rows landed with NULL. It now carries the source's `_prov` across. A copied row did not originate in the destination, so the source's record is the true one; re-stamping it here would claim an origin that is not where the data came from. `heading.as_sql` resolves an explicitly named hidden attribute against the full attribute set. Default field lists are built from `attributes` and still never contain hidden names, so nothing else changes. 681 passed, 14 skipped.
Inside the pipeline provenance is structural: a Compute row cannot exist unless its declared upstream does, so the foreign-key graph is the lineage. At the boundary that runs out. Rows arrive in Entry tables from a person, an instrument, or a feed, and until now each pipeline invented its own record of where they came from -- a source_file column here, a notes varchar there, or nothing at all. Adds a hidden `_prov` JSON attribute to Entry (dj.Manual) tables, declared by default and filled on insert. The attribute is framework-owned: no author ever writes it. `insert` gains no argument, and the content comes from three places, none of them the call site: - configuration -- `config.provenance.source`, set per deployment - ambient connection state -- user, host, database, insert time, code version - ambient execution state -- the ingesting table and key, inside a `make()` That third source is what makes a fan-out write traceable: a row written into an Entry table from inside an ingesting `make()` records what wrote it without any foreign key. Ownership is the point -- a field an operator can set is weaker evidence than one the system sets, and nothing is left for a pipeline to neglect. Anything an author wants to record deliberately belongs in the model as a visible attribute, where queries can reach it. Entry tables only. Compute and Ingest have no use for the slot -- their provenance is entailed by the graph, and job metadata already records agent, time and version for them -- while a part inherits its master's. Capture defaults on. Off by default would leave no consumer able to assume the column exists and would turn "which Entry rows have no recorded origin" into a question conditional on each table's declaration-time config. `deploy.add_prov_column` adds the slot to tables declared earlier. It sits in deploy rather than migrate because it is idempotent and stays useful for as long as capture can be turned off, which outlives the migration module's scheduled removal.
…insert Both from review of #1555. Job tables were getting `_prov`. `is_entry_table` excluded the other tiers' prefixes one at a time -- `_`, `#`, and `__` for parts -- and `~` was not among them, so `~~analysis`, `~jobs` and `~lineage` all passed. The column really landed and every job row carried a payload; on a large queue that is a lot of JSON nobody asked for. It only surfaced after `jobs.refresh()` materialises the table, which is why a plain populate() in the first round of tests missed it. Now matched against `Manual.tier_regexp`. Enumerating what to exclude makes every tier added later an Entry table until someone remembers this function; matching the tier definition inverts that, so a name is an Entry table only if the library says it is. A `source` that json could not render broke every insert. `config.provenance.source` is deployment-supplied and typed `dict[str, Any]`, so a `date` in it raised `TypeError: Object of type date is not JSON serializable` from inside every insert into every Entry table, naming neither provenance nor the setting responsible. Three layers close it: - `serialize` passes `default=str`, so ordinary values a deployment would actually set -- dates, paths -- record correctly rather than failing; - a validator on the field rejects what remains (non-string keys, cycles) at assignment, where the error belongs; - `_attach_provenance` catches and logs, so recording where a row came from can never stop the row being written. That guarantee previously covered only the version call. Also fixes the `datajoint.migrate.add_prov_column` reference in the settings description -- it lives in `deploy`, and that string shows up in config help. Regression tests fail against the previous code: 4 of them, including the integration one that drives a real job queue.
…T ... SELECT Three follow-ups from review of #1555. The Entry-table test is now inlined at its two call sites rather than wrapped in `provenance.is_entry_table`. The import stays deferred inside the function: user_tables imports table, which imports declare, so a module-scope import is a cycle -- which is what the wrapper had been hiding. `insert(QueryExpression)` builds INSERT ... SELECT and returned before the provenance was attached, so copied rows landed with NULL. It now carries the source's `_prov` across. A copied row did not originate in the destination, so the source's record is the true one; re-stamping it here would claim an origin that is not where the data came from. `heading.as_sql` resolves an explicitly named hidden attribute against the full attribute set. Default field lists are built from `attributes` and still never contain hidden names, so nothing else changes. 681 passed, 14 skipped.
d745587 to
b37dd2b
Compare
The provenance serialization test asserted the rendered Path equalled
'/mnt/raw'. On Windows str(Path('/mnt/raw')) is '\\mnt\\raw', so the
assertion failed on both Windows jobs while passing everywhere else.
The behavior under test is unchanged and was always correct: default=str
stringifies a Path rather than raising. Only the expectation was platform-
specific.
|
Re-checked on 4702d24 — the Two worth carrying forward, neither holding this up:
LGTM. |
Closes #1547. Implements the settled design. Documentation is tracked in datajoint/datajoint-docs#284, which this unblocks — the reference spec lands there as
reference/specs/boundary-provenance.md.What this adds
A hidden
_provJSON attribute on Entry (dj.Manual) tables, declared by default and filled on insert.No author ever writes it.
insertgains no argument, and the content comes from three places, none of them the call site:config.provenance.sourceconn_infouser/host/database, insert time,_get_job_versionmake()The third is what makes the fan-out pattern traceable: a row written into an Entry table from inside an ingesting
make()records what wrote it, with no foreign key back.That ownership is the point rather than a limitation. A field an operator can set is weaker evidence than one the system sets, which is what ALCOA+ attributability wants — and it retires the adoption risk an author-supplied field carries, since there is nothing left for a pipeline to neglect. Anything an author wants to record deliberately belongs in the model as a visible attribute, where queries can reach it; hidden attributes are excluded from query composition by design.
Entry tables only
Compute and Ingest get nothing. Their provenance is entailed by the foreign-key graph, and
config.jobs.add_job_metadataalready records agent, time and version for them — a_provthere would record the same facts twice while missing the half that matters (which external resource amake()actually read, which is per-row and belongs with consumed-input capture). A part inherits its master's.Capture defaults on
add_job_metadatadefaults toFalse, which is whymigrate.add_job_metadata_columnsexists. Repeating that would leave no consumer able to assume the column exists, and would make "which Entry rows have no recorded origin" conditional on each table's declaration-time config rather than a query. Stated plainly: an Entry table declared under this change differs in DDL from one declared before it. The difference is additive and hidden.Files
provenance.py(new)settings.pyProvenanceSettings,env_prefix="DJ_PROVENANCE_", asconfig.provenancedeclare.pyadapters/{base,mysql,postgres}.pyprovenance_columns()—json/jsonbtable.py_attach_provenanceon the insert pathautopopulate.pymake(), released in the existingfinallydeploy.pyadd_prov_columnretrofitOn the retrofit's home. It sits in
deployrather thanmigratebecausemigrateis deprecated for removal in 2.4/2.5, while every existing deployment needs this retrofit and will still need it afterwards whenever capture has been off. It is idempotent by construction, which isdeploy's stated contract.Verification
_provnow lands on every Entry table by default._prov, the fan-out write recording the ingesting key, and the capture-off →add_prov_column→ retrofit path including that pre-existing rows keep NULL.ruff,ruff-format,codespellandmypypass via pre-commit; mypy caught two real type errors that are fixed here.DJ_PROVENANCE_SOURCE(JSON) andDJ_PROVENANCE_CAPTUREinject through the environment, which is the Platform's per-project path.The diff is purely additive apart from one docstring line in
deploy.py.Left open deliberately
condition.pytranslates JSON paths tojson_value()on MySQL andjsonb_extract_path_text()on PostgreSQL.allow_direct_insertshould require_provstays out of scope: that is enforcement, which is a Platform concern.