Skip to content

Let a client delete a stored economy failure so the next request starts a new run - #4198

Merged
anth-volk merged 5 commits into
masterfrom
feat/economy-delete-failed-result
Nov 5, 2026
Merged

anth-volk merged 5 commits into
masterfrom
feat/economy-delete-failed-result

Conversation

@anth-volk

@anth-volk anth-volk commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4197

Summary

DELETE /<country_id>/economy/<policy_id>/over/<baseline_policy_id>

  • Request (Flask, economy_routes.py). The query is parsed by AnnualEconomyQuery through parse_multidict_query, the GET's model, and both methods build the request with one helper, _annual_impact_request.
  • Selection. EconomyService.delete_failed_economic_impact validates the request as the GET does and selects the stored record with the GET's _get_reusable_impact, including its worker-app check, on a fresh copy of the options for each attempt. It submits and polls nothing.
  • Answers:
    • 204, no body: the record was a failure recorded at least 10 seconds earlier (FAILED_IMPACT_DELETE_LOCKOUT_SECONDS, from its end_time). It is removed with its scope and recent index entries, so the next GET submits a new job.
    • 409 {"status": "error", "message": …, "result": null, "delete_after_seconds": N}: a more recent failure.
    • 409 with delete_after_seconds: null: a computing or completed record.
    • 404 {"status": "error", "message": …, "result": null}: nothing is stored that the GET would answer from. That includes a record whose value cannot be decoded, and a failure stored under a worker app the request no longer resolves to; the GET would submit a new job for both.
    • 400: invalid query parameters, with the GET's own body.
    • 502 with only status, message and result: a request to the simulation service got no response.
    • 500, through the error handlers: the cache cannot be reached, a record cannot be read from it, or the record changed three times in a row (CacheCoordinationError). The selection runs inside ReformImpactCache.reads_fail_closed(), a ContextVar mode in which the GET's own lookups raise instead of reading an error as nothing stored. Outside that block the lookups behave as before.
  • Scope. Every failed annual economy job: US national, state, congressional district and place, UK national, and UK local-area breakdowns, plus typed SPM failures. A typed SPM failure's GET answers 400 rather than 502, so its body has no delete_after_seconds.
  • No authentication. Like the GET, the DELETE needs none, so any client can remove a stored failure 10 seconds after it was recorded, including a 30-day one, and the next GET reruns the job. This is what tracker task 4A · 1 decided; the 10-second wait is the only limit.

Removing only the failure that was checked

  • The script. ReformImpactCache.delete_failure finds the failure's record key through its scope index, which also finds typed failures whose worker handle was cleared. One Lua script, DELETE_UNCHANGED_RECORD, deletes the record and both index entries only if the record still holds exactly the value read.
  • What it never touches. A job a concurrent GET submits gets its own record key, so it is never touched. A record rewritten after it was read is left alone, and the service selects and checks again, up to FAILED_IMPACT_DELETE_ATTEMPTS (3) times.
  • Writes after a DELETE. ReformImpactCache.update, used for every completion and failure write, now writes the record with Redis SET … XX. A late status poll that read the failed job before the DELETE can therefore no longer write it back after the 204, and update returns None, as it does for a missing record. The index entries such a refused write adds point at no record and are skipped on read, as an expired record's are. First writes (set_reform_impact) are unchanged.

delete_after_seconds on the failed annual GET's 502

  • The value. It is the number of whole seconds, rounded up, until a DELETE is accepted, and 0 once it is. It is null whenever retry_after_seconds is null, because it is computed only when that field is.
  • The clock. ReformImpactsService takes an injectable now clock, which stamps end_time and counts the lockout. Each instance counts on its own clock, and the value is capped at 10. The docs say what clock skew between instances does to the wait.
  • Unreachable simulation service. The annual 502 carries delete_after_seconds: null; the budget-window 502 is unchanged.
  • Retry note. The state and local-area failure message now ends "… 2 minutes after this failure, or 10 seconds after it by first sending DELETE to this URL."

Other

  • CORS. The ASGI CORSMiddleware already allows DELETE and answers the preflight before Flask, and Flask-CORS's default methods include DELETE. A test covers both preflights and the CORS headers on a 409 and a 204.
  • Docs. docs/canonical-spm.md, where Say in a failed economy answer how long its failure is kept #4172 documented retry_after_seconds, now describes the DELETE and the new field. tests/contract/registry.py has no entry for the annual GET, and migration_registry.py assigns requests by path segment, so scripts/export_migration_contracts.py produces no change.
  • Observability. The cache records failure-delete (deleted, delete-skipped, coordination-failed) and fail-closed index-read events. The service logs Deleted a stored economic impact failure with the removed run's deleted_observability_id. The DELETE adds no stage span, and no span names.
  • In-memory cache stand-in. InMemoryCacheBackend now runs the new script, and set accepts xx. Any other script text is still taken as the claim store's compare-and-delete.
  • Imports. The route imports the new result types from economy_impact_types, because the v1 route contract test loads the routes with a stand-in economy_service module.

No inline noqa suppressions are added.

Testing

Only the added and modified tests were run, each selected by node ID (103 cases):

  • tests/unit/routes/test_economy_failure_deletion.py (new, 40 cases). It covers:

    • the countdown inside the lockout, and the last half second;
    • 204, then a GET that submits a new run;
    • each job kind: US national, state, district and place; UK national and local-area breakdown; and a typed SPM failure;
    • computing and completed records refused and kept;
    • an older failure behind a newer job;
    • 404, a second DELETE, and DELETEs for another region, year, policy, baseline or version;
    • query validation identical to the GET's, an omitted version, and a bare state code;
    • a failure under a worker app no longer in use, and an undecodable record;
    • concurrent changes: a GET submitting a job mid-delete, the failure expiring and being replaced, and a late poll recording it again or after a 204;
    • giving up after three changed attempts;
    • an unreachable cache and one unreadable record (500, including the body through error_bp);
    • the DELETE's 502;
    • CORS through ASGI and through Flask alone.
  • tests/unit/runtime_cache/test_reform_impact_failure_deletion.py (new, 17 cases). It covers:

    • removing the record and both index entries, including a typed failure without a worker handle, and leaving the scope's other jobs;
    • a record rewritten before the scan, between the scan and the script, or after completing;
    • a record that is already gone, non-failures refused, and cache errors;
    • a late write after a DELETE;
    • fail-closed lookups only inside the block;
    • a NaN in a typed failure's result;
    • ordinary and disabled-backend writes keeping their result.
  • Real Redis, in tests/integration/test_runtime_cache_redis.py (3 new tests, run against a local Redis with RUNTIME_CACHE_TEST_URL=redis://127.0.0.1:6391/0):

    • the script removes only the unchanged failure, and both its index entries;
    • it refuses a record rewritten just before it runs;
    • a late write cannot put a deleted failure back.

    Script mutations that delete unconditionally or skip the index removal fail these tests.

  • New unit tests elsewhere: the stand-in's script and xx emulation, the service clock and countdown, the pass-through delete, and a stored failure whose lifetime cannot be read giving no waits.

  • Existing tests updated for the new field and note text:

    • the failure replay tests, whose service clock now follows the in-memory cache's clock, so both waits count down together;
    • the request-plumbing and impact-execution failure tests, and the service mock's default;
    • the to_dict tests;
    • the unreachable-simulation tests (annual body with the field, budget-window body without);
    • the dataset-validation and refused-breakdown 502 bodies.
  • ruff check, ruff format --check, mypy and scripts/run_quality_guards.py pass.

🤖 Generated with Claude Code

…ts a new run

DELETE on the annual economy URL takes the GET's query, parsed by the same
AnnualEconomyQuery model, and selects the stored record the GET would answer
from. It removes that record and its scope and recent index entries only when
it is a failure recorded at least 10 seconds earlier (204), and refuses a more
recent failure (409 with delete_after_seconds), a computing or completed
record (409 with null), and answers 404 when nothing is stored.

The removal is one Lua script that deletes the record only if it still holds
the exact value that was read and checked, so a record rewritten in between,
or a job a concurrent GET submits under a new key, is never removed; a changed
record is selected and checked again, up to three times.

A failed GET's 502 gains delete_after_seconds: the seconds until a DELETE is
accepted, 0 once it is, and null whenever retry_after_seconds is null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.62069% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.63%. Comparing base (6d91ba0) to head (3c35f87).
⚠️ Report is 41 commits behind head on master.

Files with missing lines Patch % Lines
policyengine_api/runtime_cache/reform_impacts.py 95.45% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4198      +/-   ##
==========================================
+ Coverage   88.52%   88.63%   +0.11%     
==========================================
  Files         304      304              
  Lines       18313    18442     +129     
  Branches     3057     3084      +27     
==========================================
+ Hits        16211    16346     +135     
+ Misses       1363     1357       -6     
  Partials      739      739              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…s empty

Review findings on the DELETE for a stored economy failure:

- A late poll that read the failed job before the DELETE and wrote after it
  put the record back, so the next GET replayed the failure instead of
  starting a new run. ReformImpactCache.update now writes the record with
  Redis's SET XX, so it rewrites only a record that still exists.
- When the cache could not be read, the selection found nothing and the
  DELETE answered 404 "nothing stored". It now confirms the cache is readable
  before a 404, and raises CacheCoordinationError (500) otherwise.
- The script's refusal path is now tested: the record is rewritten after the
  scan reads it and before the script runs, on the in-memory fake and on real
  Redis (where an unconditional delete script fails the test).
- New route tests: typed SPM failures, an older failure behind a newer job,
  a bare state code, an omitted version, a DELETE for another request, and
  the DELETE's 502.
- The 10-second cap on the wait is documented as such: with clock skew
  between instances a DELETE sent after the wait can still get a 409.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v1 route contract test loads the economy routes with a stand-in
economy_service module that defines only the names the routes used before,
so importing the new types through economy_service failed there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its tests

Second review round:

- One record read that timed out still made the DELETE answer 404 "nothing
  stored": the lookups read any cache error as an empty result, and the
  readability check only read the recent index. The DELETE now selects inside
  ReformImpactCache.reads_fail_closed, a context in which those same lookups
  raise CacheCoordinationError instead, so the selection stays the GET's and
  an unreadable record or cache gives 500, never 404. confirm_readable is
  removed.
- The budget-window 502 for an unreachable simulation service no longer
  carries delete_after_seconds, since that URL takes no DELETE.
- Tests now prove the null delete_after_seconds when a stored failure's
  lifetime cannot be read, the Lua script's removal of both index entries on
  real Redis, a DELETE with another version, and the 500 body through the
  application's error handlers.
- The docs say each instance counts the 10 seconds on its own clock, and that
  after a deploy that changes the worker app the DELETE answers 404 while the
  next GET starts a new run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the wording

Third review round:

- New tests: a DELETE half a second before the 10 seconds end is refused
  with 1 second left, and a failure stored under a worker app the GET no
  longer resolves to is left in place with a 404. Each fails if the lockout
  is shortened or the selection skips the worker-app check.
- A record whose stored value cannot be decoded counts as nothing stored for
  the DELETE, as for the GET; a test pins it, and the docstrings and
  docs/canonical-spm.md now say that only an unreachable cache or a record
  the cache cannot return gives 500.
- A 404 is followed by a GET that submits a new job, or answers computing
  while another request submits one.
- The two-minute retry note on state and local-area failures now also says
  a new run can be requested 10 seconds after the failure by first sending
  DELETE to the URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk marked this pull request as ready for review November 5, 2026 17:20
@anth-volk
anth-volk merged commit a5ed714 into master Nov 5, 2026
24 checks passed
@anth-volk
anth-volk deleted the feat/economy-delete-failed-result branch November 5, 2026 17:21

This branch was previously deployed

1 inactive deployment
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.

Let a client delete a stored economy failure so the next request starts a new run

1 participant