Skip to content

Fold a processor identity into the workflow cache key — Closes #109 - #110

Draft
conradbzura wants to merge 14 commits into
masterfrom
109-processor-identity-in-cache-key
Draft

Fold a processor identity into the workflow cache key — Closes #109#110
conradbzura wants to merge 14 commits into
masterfrom
109-processor-identity-in-cache-key

Conversation

@conradbzura

Copy link
Copy Markdown
Collaborator

Summary

cache_key identified the producing processor only by its version number. Two processors claiming the same (file, artifact_kind) pair at equal processor_version derived an identical key and read back each other's artifacts as cache hits. Keys now carry the producing processor's identity as its own segment:

{dcc}/{local_id}/{artifact_kind}/{processor_id}/{md5}-v{processor_version}

Processor.processor_id defaults per subclass to its own class name, so a processor that declares nothing still derives distinct keys — forgetting is safe rather than silently aliasing. The three shipped processors pin explicit identities (tabix-interval, bam-index, passthrough) so the values survive a class rename. ProcessorRegistry.register rejects a duplicate identity, which is the only guard against two distinct classes pinning the same string.

Changing the key invalidates every cached artifact by construction, so the migration sweep ships alongside it. cfdb purge-legacy-cache clears entries under the retired scheme from either backend, dry-run by default. That sweep subsumes the orphaned .bedpe and bigInteract artifacts stranded by #108 — they are retired-scheme keys too.

Writing the tests surfaced a defect in the sweep itself. is_legacy_cache_key checked segment count and leaf shape but not the artifact-kind segment, so purge_s3 --apply with an over-specified --s3-prefix deleted live artifacts: seeding dev/encode/ENCFF1/index/tabix-interval/<md5>-v2 and sweeping with prefix="dev/encode" returned deleted=1 and emptied the bucket. Since --s3-prefix reads from WORKFLOW_S3_PREFIX, one environment-variable typo destroyed a live cache. Requiring a real artifact kind in that segment closes every mis-strip — removing one segment too many leaves the processor identity where the kind must be, and removing two leaves too few segments to match.

The deploy shipping this starts against a fully cold cache.

Closes #109

Proposed changes

Fold a processor identity into the cache key

Add processor_id to Processor, defaulted by __init_subclass__ to the subclass's own class name. Read the default from cls.__dict__ rather than the MRO: a subclass may emit different bytes at the same version, so inheriting the parent's identity would recreate the aliasing the segment prevents. A value supplied by a mixin is discarded for the same reason.

Validate a declared identity when the class is declared rather than at first use, so a malformed value fails on import instead of surfacing per-request inside a worker. Beyond the separator and null-byte guards normalize_local_id already applies, reject . and .. — caught today only by the cache backend, a different module with a different rationale — and reject a value equal to an artifact kind.

Reject two processors that share an identity

ProcessorRegistry.register raises when another registered processor already claims the identity. The class-name default cannot catch two distinct classes pinning the same explicit string, which is the realistic collision. The guard keys on identity alone, so two processors claiming one format remain legal and order-resolved.

Add the migration sweep

cfdb.workflows.purge provides purge_s3 and purge_local; cfdb purge-legacy-cache exposes both through the installed console script so the sweep runs from a deployed image. Exactly one store is purged per run — the command refuses when both an S3 bucket and a local root resolve rather than guessing.

Two failure modes are surfaced rather than absorbed. DeleteObjects reports per-key failures in the response body instead of raising, so a sweep that deleted nothing for want of an s3:DeleteObject grant would report success. A response confirming fewer keys than requested means something was neither deleted nor complained about. Both raise, and the sweep is idempotent.

The local sweep prunes only the directories its own deletions emptied. The root is operator-supplied, so an unrelated empty directory under it is not the sweep's to reclaim.

Correct stale test fixtures

tests/test_workflows/test_executor.py and tests/integration/routines.py held four-segment key literals under a comment claiming they matched production output. Both now derive their keys. The executor stub's keys reach real JobRecord.artifact_cache_keys in integration runs.

Several router cache-hit tests rebuilt the expected key with the same expression cache_key_for evaluates, so they would have passed with the identity segment removed entirely. They now seed through the processor's own derivation.

Test cases

# Test Suite Given When Then Coverage Target
1 TestCacheKey Valid inputs including a processor identity cache_key is called Returns a five-segment key with the identity fourth Key shape
2 TestCacheKey Two ids differing only in case, all else equal cache_key is called for each Returns distinct keys Case preservation
3 TestCacheKey Any two distinct valid ids, all else equal (Hypothesis) cache_key is called for each Returns distinct keys The issue's headline property, universally quantified
4 TestCacheKey Any valid input tuple (Hypothesis) cache_key is called twice Returns identical five-segment keys Determinism and arity
5 TestIsLegacyCacheKey logs/2024/01/{md5}-v2 and encode/X/wat/{md5}-v2 is_legacy_cache_key is called Returns False for both Foreign objects sharing the bucket
6 TestIsLegacyCacheKey A current key stripped of its leading segment is_legacy_cache_key is called Returns False The over-specified prefix hazard
7 TestIsLegacyCacheKey Fourteen malformed or foreign key shapes is_legacy_cache_key is called on each Returns False for all Rejection surface
8 TestIsLegacyCacheKey Any key derived by cache_key (Hypothesis) is_legacy_cache_key is called Always returns False No live artifact is ever sweepable
9 TestIsLegacyCacheKey Any key the retired scheme could mint (Hypothesis) is_legacy_cache_key is called Always returns True The migration leaves no orphan
10 TestNormalizeProcessorId An id of . or .. normalize_processor_id is called Raises ValueError Traversal rejected at derivation
11 TestNormalizeProcessorId An id equal to an artifact kind normalize_processor_id is called Raises ValueError Closes the residual prefix hole
12 TestProcessor A subclass declaring processor_id = "" The attribute is read Is the class name Falsy declaration takes the default
13 TestProcessor A subclass declaring a whitespace-only id The class is declared Raises ValueError at definition Import-time failure, not request-time
14 TestProcessor A mixin supplying an id, inherited by a processor The attribute is read Is the subclass's class name A mixin cannot supply an identity
15 TestProcessor A three-level chain declaring nothing Each attribute is read Each carries its own class name Multi-level inheritance
16 TestProcessor Two subclasses at equal version, same file and kind cache_key_for on each Returns distinct keys The property at the processor layer
17 TestProcessorRegistry Two distinct classes declaring one identity register on the second Raises, naming the id and incumbent The realistic collision
18 TestProcessorRegistry Same identity, disjoint supported_formats register is called Raises Format disjointness is not an escape
19 TestProcessorRegistry A registry that just rejected a duplicate lookup_for is called Resolves to the incumbent Rejection leaves no partial state
20 TestDefaultRegistry The exact wiring api/main.py performs All three registered No raise; each format resolves The shipped triple, never previously run through register
21 TestTabixIntervalProcessor The shipped class processor_id is read Is exactly tabix-interval Wire constant
22 TestTabixIntervalProcessor Tabix and BAM, both at version 2 cache_key_for on each Returns distinct keys The issue's motivating pair
23 TestServeWorkflowArtifactOrDispatch Cache seeded under another processor's identity, GET The helper is awaited Returns 202 and dispatches A wrong answer is now a miss
24 TestServeWorkflowArtifactOrDispatch Same seeding, HEAD The helper is awaited Raises 404, dispatches nothing Cross-processor miss, side-effect-free
25 TestServeWorkflowArtifactOrDispatch Cache seeded at a retired-scheme key, GET The helper is awaited Returns 202 and dispatches Legacy artifacts are unreachable
26 TestProbeWorkflowReadiness Cache seeded under a foreign or legacy key The probe is awaited Returns False /status agrees with the dispatch path
27 TestPurgeS3 A live key swept with prefix="dev/encode" purge_s3 runs applied Deletes nothing; the key survives Data-loss regression
28 TestPurgeS3 1001 legacy objects purge_s3 runs applied Deletes all; bucket is empty Pagination and batch boundary
29 TestPurgeS3 A client whose delete_objects reports errors purge_s3 runs applied Raises RuntimeError Partial failure cannot pass as success
30 TestPurgeS3 A client confirming fewer keys than requested purge_s3 runs applied Raises RuntimeError Silent undercount
31 TestPurgeS3 Legacy entries under dev/, dev-staging/, devops/ purge_s3 runs with prefix="dev" Deletes only the dev/ entry Textual prefix cannot cross environments
32 TestPurgeLocal A legacy entry and an unrelated empty directory purge_local runs applied The unrelated directory survives Prune is scoped to touched ancestors
33 TestPurgeLocal Any identity seeded under both schemes (Hypothesis) purge_local runs applied Deletes exactly the retired entry Round-trip across keys and purge
34 TestPurgeLegacyCacheCommand A root with one legacy and one current entry Invoked without --apply Exits 0; both files survive Dry run is the default
35 TestPurgeLegacyCacheCommand WORKFLOW_S3_BUCKET set alongside --local-root The command is invoked Exits non-zero; neither sweep runs The unrecoverable-mistake guard
36 TestPurgeLegacyCacheCommand All four options set in the environment The command is invoked Endpoint and region reach the client factory A dev sweep cannot reach real AWS
37 TestLifespanProcessorRegistry The lifespan with default_registry unpatched Startup runs Every shipped format resolves Closes the MagicMock hole in the existing lifespan test
38 TestWoolExecutor A completed workflow The job record is read Persisted keys equal cache_key_for Producer, record, and probe agree

1219 unit tests and 76 integration tests pass. The suite was mutation-checked rather than trusted green: removing the identity segment fails 26 tests including all five router-seam tests, and removing the artifact-kind guard fails exactly the five data-loss regression tests.

Deploy notes

Run cfdb purge-legacy-cache against each environment after this lands. Pass --s3-prefix exactly as WORKFLOW_S3_PREFIX is set for that environment; a prefix carrying extra segments is rejected by the artifact-kind check rather than acted on, and a prefix that is too short matches nothing.

cache_key identified the producing processor only by its version number,
never by which processor it is. Two processors claiming the same file and
artifact kind at equal processor_version therefore derived an identical
key and read back each other's artifacts as cache hits — a wrong answer
rather than a miss. Nothing collided, but only by accident of the
registry: TabixIntervalProcessor and BamIndexProcessor both sit at
version 2 and stayed apart solely because their supported_formats are
disjoint.

Keys now carry the producing processor's identity as its own segment:

    {dcc}/{local_id}/{artifact_kind}/{processor_id}/{md5}-v{version}

Processor gains a processor_id class attribute, defaulted per subclass to
its own class name so a processor that declares nothing still derives
distinct keys — forgetting is safe rather than silently aliasing. The
three shipped processors pin explicit identities instead, so the values
survive a class rename; changing one invalidates every artifact keyed
under it. The default is read from the subclass's own __dict__ rather
than the MRO, because a subclass may emit different bytes at the same
version, and inheriting the parent's identity would recreate exactly the
aliasing the segment prevents.

An identity is validated when the class is declared rather than at first
use, so a malformed value fails on import instead of surfacing
per-request inside a worker. Beyond the separator and null-byte guards
normalize_local_id already applies, an identity may not be "." or ".."
— caught today only by the cache backend, a different module with a
different rationale — nor equal an artifact kind, which would let an
over-specified purge prefix strip a live key down to something shaped
exactly like a retired one.

is_legacy_cache_key describes the retired four-segment scheme for the
migration sweep. It requires a real artifact kind in the third segment,
not merely the right segment count: it gates an irreversible delete, so a
false positive is data loss while a false negative only leaves a stale
object behind. That check is also what makes a mis-specified purge prefix
safe, since stripping one segment too many leaves the processor identity
where an artifact kind must be.
The class-name default keeps processors that declare nothing apart, but
it cannot catch the case that actually matters: two distinct classes
pinning the same explicit processor_id, as a new processor copy-pasting
a shipped identity would. Cache keys are scoped by that identity, so the
two would read back each other's artifacts as cache hits.

Rejecting at wiring time makes the property an enforced invariant rather
than a convention every new processor has to remember, and fails in CI
instead of at API startup. The guard keys on the identity alone, so two
processors claiming one format remain legal and order-resolved — the
documented first-registration-wins contract is unchanged.
Folding a processor identity into the cache key invalidates every
existing artifact by construction — the API derives the five-segment
shape and never probes the old one — so the entries are unreachable
storage cost until swept. The sweep also clears the orphaned .bedpe and
bigInteract index artifacts stranded when those formats were re-typed;
they are retired-scheme keys too, so they need no separate pass.

Both backends are covered because a deployment runs one or the other,
and cfdb purge-legacy-cache exposes them through the installed console
script so the sweep can run from a deployed image. It is a dry run
unless --apply is passed, and it refuses to act when both an S3 bucket
and a local root resolve rather than guessing which cache was meant —
WORKFLOW_S3_BUCKET in the environment is enough to reach that state
alongside an explicit --local-root, and purging the wrong store cannot
be undone.

Two failure modes are surfaced rather than absorbed. DeleteObjects
reports per-key failures in the response body instead of raising, so a
sweep that deleted nothing for want of an s3:DeleteObject grant would
otherwise report success; and a response confirming fewer keys than were
requested means something was neither deleted nor complained about. Both
raise, and the sweep is idempotent, so re-running after fixing the cause
picks up whatever is left.

The local sweep prunes only the directories its own deletions emptied.
The root is operator-supplied, so an unrelated empty directory under it
is not the sweep's to reclaim.
Pins the property the issue asks to enforce: two processors claiming the
same file and artifact kind at equal processor_version derive different
keys. Covered as an example at both the keys and the Processor layer, as
a Hypothesis property over the whole input domain, and against the
shipped TabixIntervalProcessor and BamIndexProcessor pair the issue names
as staying apart only by accident.

is_legacy_cache_key gets the heaviest coverage, because it decides what
an irreversible sweep deletes. Two properties bracket it: no key
cache_key can mint is ever claimed, so a live artifact cannot be swept;
and every key the retired scheme could mint is claimed, so the migration
leaves no orphan. Around them sit the rejection cases — wrong segment
counts, blank segments, foreign objects sharing the bucket, a
workflow_key, and a current key stripped of its leading segment as an
over-specified purge prefix produces.

The shipped processor identities are asserted as literals rather than
derived from the classes. They are wire constants: every cached artifact
is keyed under one, the README documents "tabix-interval" inside an
example key, and changing a string silently invalidates that processor's
whole cached corpus. Deriving them would assert nothing.

The duplicate-registration test previously registered one class twice,
which the class-name default makes nearly unreachable. It now uses two
distinct classes pinning one identity, and asserts the message names the
identity and the incumbent, since that message is the operator's only
diagnostic. The ordering test needed a second BAM-claiming class for the
same reason, so it now also documents that the guard is scoped by
identity rather than by format.
cfdb.cli had no test module at all, and this change adds a command to it
that deletes production data irrecoverably. Both usage guards are now
covered — the no-target case and the ambiguous case where an S3 bucket
and a local root both resolve — and the ambiguity test asserts that
neither sweep ran, not merely that the message was printed.

An autouse fixture clears the five environment variables the options
bind to. Every one of them leaks from a developer's shell, and without
the fixture the ambiguity guard would fire on tests that never mention
S3, passing in CI and failing locally.

The sweep's boundaries get direct coverage: pagination across more than
one listing page, the delete-request batch boundary, prefixes written
bare or slashed, and a neighbouring environment whose prefix is a strict
textual prefix of the swept one. The over-specified prefix is a
regression test — it deletes a live artifact without the artifact-kind
check in is_legacy_cache_key.

The DeleteObjects error path uses a client double rather than moto,
which populates Errors only for versioned deletes the sweep never
issues. purge_s3 takes its client as a public parameter, so the double
stays on the public surface.

A Hypothesis property spans the two modules: for any file identity
seeded under both schemes, the sweep deletes exactly the retired entry
and leaves what cache_key mints. That is what catches the deriver and
the sweep drifting apart in a future key change.
The identity property was pinned only at key derivation, never where a
wrong answer would actually be served. The router now has to prove that
an artifact cached under another processor's identity is a miss on GET,
on HEAD, and on the readiness probe, and that an artifact sitting at a
retired-scheme key is likewise unreachable — the latter being what makes
the sweep safe to run and what leaves the shipping deploy cold.

Several cache-hit tests had become tautologies. They rebuilt the expected
key with the same expression cache_key_for evaluates, so they would have
kept passing if the identity segment were dropped entirely. They now seed
through the processor's own derivation, which is the agreement the router
probe and the processor put actually depend on.

Two fixtures held four-segment key literals under a comment claiming they
matched what cache_key produces in production. That stopped being true,
and the executor stub's keys reach real JobRecord.artifact_cache_keys in
integration runs, so both are now derived. The executor also asserts the
persisted keys equal what the processor derives, closing the chain from
producer through job record to router probe.

The end-to-end suites asserted only that a cached path existed, which
holds under any key scheme. They now check the shape a real processor
driven through a real worker actually wrote.

test_lifespan_registry covers a hole the duplicate-identity guard made
consequential: the existing lifespan test replaces default_registry with
a MagicMock, so no test had ever run register against a registry already
holding PassthroughProcessor. It also pins that startup builds a fresh
registry, the one assignment keeping a re-entered lifespan from turning
the guard into a boot crash-loop.

Remaining changes are call-site updates for the new cache_key signature.
@conradbzura conradbzura self-assigned this Aug 19, 2026
install_jobs_index was a synchronous fixture calling FakeCollection.create_index,
which is a coroutine matching Motor's API. The coroutine was never awaited, so
the partial-unique index on workflow_key was never created and the mutex it
backs was absent for every test that requested the fixture.

The concurrent-dedup assertions in test_concurrency.py have therefore never
exercised the mutex they name. They failed on two distinct job ids and were
absorbed as expected failures by an unrelated known-bug entry, so the gap was
invisible. With the index installed they pass on the real behaviour.
The tabix_macos_sigpipe_under_load entry listed AssertionError in both its
raises tuple and its retryable predicate. It matches on format alone, covering
nine of the eleven formats, so it absorbed every assertion failure in those
scenarios on every platform and reported each as an expected failure caused by
a subprocess SIGPIPE that may not have occurred.

AssertionError is the type every assertion in the suite raises, so this made
the affected tests unable to fail: deleting the processor identity segment from
cache_key produced eight expected failures and exit code 0. Ten genuinely
failing concurrency tests were being reported the same way.

A known-bug entry has to name the failure it actually knows about, so the entry
now matches only the RuntimeError carrying the SIGPIPE signature.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fold a processor identity into the workflow cache key

1 participant