Skip to content

feat(prefect): re-enable content caching with stable file persistence#286

Merged
jqnatividad merged 1 commit into
mainfrom
reenable-result-caching
May 16, 2026
Merged

feat(prefect): re-enable content caching with stable file persistence#286
jqnatividad merged 1 commit into
mainfrom
reenable-result-caching

Conversation

@jqnatividad

Copy link
Copy Markdown
Collaborator

Summary

Re-enables cache_policy= on the read-only stages and clears the file-path blocker that forced caching off in the first place (the "next step" called out in caching.py's pre-existing docstring). Closes the result-caching item on #282.

Why caching was off

Each per-stage result dataclass carries working file paths that point into a per-run TemporaryDirectory. Without persistence, a cached result from an earlier run referenced a tempdir that no longer exists, so a cache hit would either error or silently misread stale data downstream.

What this PR adds

  • New jobs/file_persistence.pypersist_file(local_path, key) + restore_file(key, dest_path), both with graceful no-op fallback when the storage block isn't available.
  • Result dataclasses gain optional *_path_key fields (back-compat: cached results predating the field deserialize cleanly).
  • Task bodies persist their working file at completion via a tiny _persist_stage_file(...) helper using the content-hash + stage + slot as a stable key.
  • _apply_result routes every file path through a new _resolve_or_restore(...) helper: local path if it still exists (same-run); otherwise restore from storage into the current tempdir (cross-run cache hit); otherwise pass the original path through so the downstream gets a clear FileNotFoundError instead of silent staleness.
  • cache_policy= re-enabled on download_task, format_convert_task, validate_task, analyze_task with cache_expiration=DEFAULT_CACHE_EXPIRATION (24h default). Mutating tasks (database, indexing, formula, metadata) intentionally still do not cache — caching the result would skip the side effect.

Correctness fix in caching.py

The pre-existing CacheKeyFnPolicy(cache_key_fn=...) + TASK_SOURCE composition has a subtle hazard verified empirically against Prefect 3.7: when cache_key_fn returns None (no file_hash yet, or operator passed ignore_hash=True), Prefect's CompoundCachePolicy.compute_key drops the None contribution and hashes the rest — so the compound key collapses to just the TASK_SOURCE hash, identical for every invocation of the same task source regardless of parameters. Two unrelated runs with no content key would hit each other's cache.

Fix: drop the + TASK_SOURCE composition. The cost is that a DP+ upgrade changing a task body no longer auto-invalidates older cached output; the 24-hour default TTL bounds the staleness window. Operators wanting fresh output post-upgrade can clear the storage block or shorten DATAPUSHER_PLUS_CACHE_TTL_HOURS. Documented in caching.py.

Tests

New tests/test_file_persistence.py with 8 cases covering persist/restore round-trip, graceful degradation paths, and the three _resolve_or_restore branches (local-exists / storage-restore / fall-through to clear error).

Verified locally in the dpp-test ckan-dev container: 60/60 unit tests pass (52 prior + 8 new).

Test plan

  • ci.yml (DataPusher+ Integration CI) — the 7-file quick-dir matrix exercises the same task chain end-to-end with a real Prefect server + worker. Caching hits should be harmless on the first run (cache empty) and should make subsequent runs of the same files faster.

🤖 Generated with Claude Code

Re-enables `cache_policy=...` on the read-only stages (download,
format_convert, validate, analyze) and fixes the file-path blocker
that forced caching off in the first place (the "next step" called
out in `caching.py`'s pre-existing docstring).

## Why it was off

Each per-stage result dataclass carries working file paths
(`downloaded_path`, `csv_path`, `quarantine_csv_path`) that point into
a per-flow-run `TemporaryDirectory`. Without persistence, a cached
result from an earlier run referenced a tempdir that no longer
exists, so a cache hit would either error out or silently mis-read
stale data downstream.

## What this PR adds

* **`jobs/file_persistence.py`** (new): `persist_file(local_path, key)`
  writes a local file to the result-storage block under a stable
  key; `restore_file(key, dest_path)` reads it back. Both degrade
  gracefully (return `None` / `False`) when the block is unavailable.

* **Result dataclasses gain `*_path_key` fields**
  (`DownloadResult.downloaded_path_key`, `ConvertResult.csv_path_key`,
  `ValidateResult.csv_path_key`, `ValidateResult.quarantine_csv_path_key`,
  `AnalyzeResult.csv_path_key`). Optional + defaulting to `None`, so
  cached results from older runs predating this field deserialize
  cleanly and just behave as "no cross-run rehydration".

* **Task bodies persist their working file at completion** via a tiny
  `_persist_stage_file(local_path, file_hash, stage, slot)` helper.
  Skips silently when the storage block is unavailable; same-run
  chains continue to work either way.

* **`_apply_result` routes every file path through a new
  `_resolve_or_restore(local_path, storage_key, dest_dir)` helper**:
  use the local path if it exists (same-run); otherwise restore
  into the current run's tempdir from the persisted key; otherwise
  return the original path unchanged so the downstream stage raises
  a clear `FileNotFoundError` instead of appearing to succeed on
  stale data.

* **Re-enabled `cache_policy=DOWNLOAD_CACHE_POLICY` /
  `CONTENT_CACHE_POLICY`** on the four read-only tasks, with
  `cache_expiration=DEFAULT_CACHE_EXPIRATION` (24 hours by default).
  The mutating tasks (database, indexing, formula, metadata)
  intentionally still do NOT cache — caching their results would
  skip the side effect.

## A correctness fix in caching.py

The pre-existing `CacheKeyFnPolicy(cache_key_fn=...) + TASK_SOURCE`
composition has a hazard verified empirically against Prefect 3.7:
Prefect's `CompoundCachePolicy.compute_key` drops `None` contributions
and hashes the rest, so when `cache_key_fn` returns `None` (no
`file_hash` yet, or operator set `ignore_hash=True`), the compound
key collapses to just the TASK_SOURCE hash — identical for every
invocation of the same task source regardless of parameters. Two
unrelated runs with no content key would hit each other's cache.

Fix: drop the `+ TASK_SOURCE` composition. Cost is that a DP+
upgrade changing a task body no longer auto-invalidates older cached
output; the 24-hour default TTL bounds the staleness window, and
operators wanting fresh output post-upgrade can clear the storage
block or shorten `DATAPUSHER_PLUS_CACHE_TTL_HOURS`. Documented.

## Tests

New `tests/test_file_persistence.py` covers `persist_file`,
`restore_file`, and `_resolve_or_restore`:
- Round-trip persistence + restore
- Graceful degradation when block unavailable
- Graceful degradation when local file missing
- `_resolve_or_restore` returns local path when file exists
- `_resolve_or_restore` restores from storage when path missing
- `_resolve_or_restore` returns original (broken) path when neither
  works, so downstream gets a clear FileNotFoundError
- Pass-through for None / empty inputs

Verified in `dpp-test`: **60/60 unit tests pass** (52 prior + 8 new).

Tracking: closes the result-caching item on #282.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jqnatividad jqnatividad merged commit 8f53a6b into main May 16, 2026
1 check passed
@jqnatividad jqnatividad deleted the reenable-result-caching branch May 16, 2026 03:45
jqnatividad added a commit that referenced this pull request May 16, 2026
fix(prefect): address roborev review on #286
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.

1 participant