staleness
Read-before-compute helper shared by the per-file calculation steps.
A calculation step writes one row per (task_hash, file_id), derived from its
upstream prediction rows plus its own config. It can therefore skip any file
whose row is already present and no older than the predictions it was computed
from. This module holds that rule, and the chunked prediction read that goes
with it, so the five calculation steps cannot drift apart.
The rule is sound only because task_hash is a per-step Merkle hash over the
step's own semantic config and its parents' hashes
(bitfount.flows.dag.hashing, and see bitfount.steps.cache_view): a config
change or a model-version change lands in a fresh partition, so neither needs
detecting here. What is left to detect is freshness relative to the upstream
rows, which is what stale_file_ids does.
The rule also requires every stale file with nothing recorded for it to end up
with a row, or it stays stale for ever; backfill_missing_data over
unrecorded_file_ids is the other half of the contract, and each of the five
steps applies it before persisting. The one thing that must never be written off
is a batch the datasource failed on rather than the file — see
metadata_backfill_reason and DATASOURCE_METADATA_UNAVAILABLE — and no
datasource miss, wholesale or per-file, may replace a metric already on file:
preserve_existing_metrics is that guard for the reasons a step's own loop
records, as unrecorded_file_ids is for the backfill.
Accepted limitation — the read-then-persist window
A row's processed_at is the wall clock when the row was written, which is
later than the source read it was computed from. A prediction written inside
that window carries a timestamp older than the row that does not include it, so
the source_timestamp > processed_at branch of stale_file_ids never sees it:
the file keeps metrics derived from the older prediction, silently and for ever.
The window is as wide as the step's own runtime — seconds on an incremental run,
much longer on a first pass over a large inventory.
Reaching the window needs a second writer on the same source partition while a
calculation step's body runs. Two mechanisms rule that out for every DAG shape
in the tree today: the executor awaits every FromRef dependency before a task
body starts, so a step's own inference parents are complete before it reads; and
flow-run dedup stops two runs of the same DAG overlapping. What remains is two
concurrent runs of different DAGs whose inference steps hash to the same
partition, one re-inferencing a file edited in place, or a write to the partition
from outside the DAG.
Module
Functions
backfill_missing_data
def backfill_missing_data( output: dict[str, _MetricT | str | None], candidates: Iterable[str], reason: str,) ‑> dict[str, typing.Union[~_MetricT, str, NoneType]]:Record reason against every candidate output holds no entry for.
A calculation step is asked for candidates (its stale set) but only
produces an entry for the files its inputs could carry all the way through:
a file the datasource cannot load never reaches get_data_for_files's
output, and a file with no prediction row is scoped out by the thickness
runner. Without a row of its own such a file trips the row is None branch
of stale_file_ids on every run for ever — the empty-stale-set short-circuit
can then never fire, and the file's bytes are re-read on every tick where
anything else is stale. That is exactly the cost the missing_data:
non-retry rule exists to avoid, so the file is written off explicitly here.
Writing the row does NOT abandon a file that is merely awaiting inference.
The row makes the file settled under the not error.startswith(...) test
only; source_timestamp > processed_at still revives it the moment any source
holds a processed_at newer than the row's, so a prediction landing after
this row recomputes the file — with the one exception the module docstring
records, a prediction written while this very run was between its source read
and this write.
Arguments
output: The step's per-file result map, modified in place.candidates: The stale ids that hold no result worth keeping — passunrecorded_file_ids(stale, own), never the raw stale set, or a run that fails to recompute an already-computed file replaces its metric with a permanent null.reason: Themissing_data:reason to record —MISSING_DATA_NO_PREDICTIONorMISSING_DATA_METADATA.
Returns The same output mapping, for use as an expression.
Raises
ValueError: If reason is not amissing_data:reason. Any other prefix is retried by thenot error.startswith(...)branch ofstale_file_ids, which would defeat the purpose of the backfill and re-read every unloadable file on every run.
metadata_backfill_reason
def metadata_backfill_reason(metadata_df: pd.DataFrame) ‑> str | None:Return the reason to write off files the datasource metadata frame lacks.
MISSING_DATA_METADATA settles a file permanently, and that is only correct
when the datasource answered for the batch and dropped this file — an
unloadable scan, a non-scan artifact. When it returned nothing at all the
same write would settle every stale file in the batch on one transient
failure (see DATASOURCE_METADATA_UNAVAILABLE), so no backfill is done and
the files stay stale for the next run.
Arguments
metadata_df: The frameget_data_for_filesreturned for the stale set.
Returns
MISSING_DATA_METADATA when the frame holds at least one row, else
None — pass straight to a step's backfill, which skips on None.
preserve_existing_metrics
def preserve_existing_metrics( output: dict[str, _MetricT | str | None], unrecorded: Iterable[str],) ‑> dict[str, typing.Union[~_MetricT, str, NoneType]]:Drop metadata-miss reasons output holds for files that have a metric.
unrecorded_file_ids keeps a failed run from stamping a missing_data:
reason over a metric already on file, but it guards only the backfill: a
reason the step's own loop placed in output directly bypasses it, and
bulk_merge writes the whole row, so persisting it takes the metric to NULL.
The thickness runner does exactly that — it records
MISSING_DATA_METADATA / DATASOURCE_METADATA_UNAVAILABLE for any file the
datasource returned no row for, including one that is stale only because an
upstream prediction became newer (source_timestamp > processed_at) and
still holds a perfectly good metric from a previous run.
Neither reason says anything about the file: the datasource simply did not
answer for it this time. Removing the entry leaves the previous row intact
and the file stale for the next run, which is the recoverable direction — and
because the file is recorded, the caller's tail
MISSING_DATA_NO_PREDICTION backfill skips it too. A computed metric and a
calculation_error: from the per-file maths are both left alone: those are
this run's answer for the file, not the absence of one.
Arguments
output: The step's per-file result map, modified in place.unrecorded: The stale ids that hold no metric — pass the sameunrecorded_file_ids(stale, own)list given to the tail backfill. Any id carrying a metadata-miss reason and absent from it already has a metric, and its entry is dropped.
Returns The same output mapping, for use as an expression.
read_for_file_ids
def read_for_file_ids( accessor: CacheAccessor, file_ids: Sequence[str], *, chunk_size: int = 500,) ‑> pandas.core.frame.DataFrame:Read accessor's rows for file_ids only, in bounded chunks.
The filter is built from a free-standing column("file_id") rather than an
ORM attribute: an accessor always queries exactly one table, so the
unqualified name resolves, and a calculation step therefore never has to
import another step's versioned ORM (model_inferences is at v2 while the
calculation records are at v1).
An empty file_ids still issues one read, so the returned frame carries the
partition's columns — callers guard on .empty and index file_id, and a
column-less frame would break them.
Arguments
accessor: The partition to read.file_ids: The ids to restrict the read to.chunk_size: Ids perIN (...)clause.
Returns
A pandas.DataFrame of the matching rows, with a fresh index.
stale_file_ids
def stale_file_ids( candidates: Iterable[str], own: CacheAccessor, sources: Sequence[CacheAccessor],) ‑> list[str]:Return the subset of candidates whose result is missing or stale.
A file_id is stale when any of the branches in the loop below adds it —
each is named here by its own condition, and the rest of this module refers
to them that way:
row is None— the file has no row in own at all.not error.startswith(...)— its row carries anerrorthat is not amissing_data:reason. The inverse, a row whose error is amissing_data:reason, is the settled case and falls through.not has_metric— its row records neither a metric nor a reason, so it says nothing about the file and cannot be a settled result. Every table here holds exactly one ofmetrics_jsonanderror, and the current writers keep that invariant, but the first cut of the shared thickness runner returned a bareNonefor an uncomputable file and its writer set noerrorat all (SDK12.0.0-beta.74to.77). A pod cache that predates the reason strings can therefore hold such rows. Skipping them would strand the file at a null metric for ever, because nothing else in this rule inspects the metric.processed_at is None— its row's timestamp will not parse, so no comparison is possible and the file recomputes.source_timestamp > processed_at— a row for it in sources is strictly newer than its own row's. See the read-then-persist window in the module docstring for the one case this comparison cannot see.
source_timestamp > processed_at is what a presence-only check lacks, and it
covers three cases a calculation step actually meets: a prediction that had
not landed when the row was first written (these steps left-merge, so such a
file still gets a placeholder row); a file edited in place, which
re-inference rewrites; and any upstream re-run.
Arguments
candidates: The file ids the step was asked to process.own: An accessor over the step's own output partition.sources: Accessors over the upstream prediction partitions this step computes from.
Returns The stale ids, sorted and deduplicated, so the caller's downstream ordering is deterministic.
unrecorded_file_ids
def unrecorded_file_ids(candidates: Iterable[str], own: CacheAccessor) ‑> list[str]:Return the candidates whose own row holds no result worth keeping.
These are the ids a missing_data: backfill may be written for: each has
either no row at all (row is None in stale_file_ids) or a row carrying no
computed metric — whether that is a row whose error is set (settled or
not), or one of the pre-reason-string rows that hold neither (see the
not has_metric branch of stale_file_ids). The test is metric presence
rather than error presence precisely so that second shape is covered: it is
stale on every run and, without a backfill, has no way to settle.
A candidate whose row carries a computed metric is deliberately excluded. It
is in the stale set only because an upstream row became newer
(source_timestamp > processed_at), and a run that then fails to recompute it
— a datasource that cannot load the file this time — must not stamp
missing_data: over the metric it already has:
bulk_merge writes the whole row, so the metric would go to NULL, and a
missing_data: row is never retried, so it would stay NULL for ever. Leaving
such a file without a row this run keeps it stale for the next one, which is
the recoverable direction.
Arguments
candidates: The stale ids the step was asked to process.own: An accessor over the step's own output partition.
Returns
The subset of candidates safe to write a missing_data: reason for, in
the order given.