Skip to content

Commit dd794ff

Browse files
committed
Harden budget-window batch failure handling
1 parent dcf7487 commit dd794ff

4 files changed

Lines changed: 136 additions & 4 deletions

File tree

projects/policyengine-api-simulation/src/modal/budget_window_batch.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@
2929
)
3030

3131
POLL_INTERVAL_SECONDS = 0.1
32+
REQUIRED_BUDGET_KEYS = (
33+
"tax_revenue_impact",
34+
"state_tax_revenue_impact",
35+
"benefit_spending_impact",
36+
"budgetary_impact",
37+
)
3238

3339

3440
def _extract_request_and_metadata(
@@ -87,6 +93,20 @@ def extract_budget_window_annual_impact(
8793
*, year: str, impact_data: dict[str, Any]
8894
) -> BudgetWindowAnnualImpact:
8995
budget = impact_data.get("budget", {})
96+
if not isinstance(budget, dict):
97+
raise ValueError("Malformed budget-window child result: missing budget object")
98+
99+
missing_keys = [
100+
key
101+
for key in REQUIRED_BUDGET_KEYS
102+
if not isinstance(budget.get(key), int | float)
103+
]
104+
if missing_keys:
105+
missing = ", ".join(f"budget.{key}" for key in missing_keys)
106+
raise ValueError(
107+
f"Malformed budget-window child result: missing numeric {missing}"
108+
)
109+
90110
state_tax_revenue_impact = budget.get("state_tax_revenue_impact", 0)
91111
tax_revenue_impact = budget.get("tax_revenue_impact", 0)
92112

@@ -190,10 +210,18 @@ def run_budget_window_batch_impl(params: dict[str, Any]) -> dict[str, Any]:
190210
put_batch_job_state(state)
191211
return _failure_response(state)
192212

193-
annual_impact = extract_budget_window_annual_impact(
194-
year=year,
195-
impact_data=result,
196-
)
213+
try:
214+
annual_impact = extract_budget_window_annual_impact(
215+
year=year,
216+
impact_data=result,
217+
)
218+
except Exception as exc:
219+
error = str(exc)
220+
mark_child_failed(state, year=year, error=error)
221+
mark_batch_failed(state, error=error)
222+
put_batch_job_state(state)
223+
return _failure_response(state)
224+
197225
mark_child_completed(state, year=year, annual_impact=annual_impact)
198226
put_batch_job_state(state)
199227
progress_made = True

projects/policyengine-api-simulation/src/modal/budget_window_state.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,19 @@ def mark_batch_failed(
187187
*,
188188
error: str,
189189
) -> BudgetWindowBatchState:
190+
for year in list(state.running_years):
191+
child = state.child_jobs.get(year)
192+
if child is None:
193+
continue
194+
state.child_jobs[year] = BatchChildJobStatus(
195+
job_id=child.job_id,
196+
status="cancelled",
197+
error=error,
198+
)
199+
190200
state.status = "failed"
191201
state.error = error
202+
state.running_years = []
192203
return _touch(state)
193204

194205

projects/policyengine-api-simulation/tests/gateway/test_budget_window_state.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
get_batch_job_state,
77
mark_batch_complete,
88
mark_batch_running,
9+
mark_batch_failed,
910
mark_child_completed,
1011
mark_child_failed,
1112
mark_child_started,
@@ -198,3 +199,33 @@ def test_state_transition_helpers_track_failed_child():
198199
assert state.status == "running"
199200
assert state.failed_years == ["2027"]
200201
assert state.child_jobs["2027"].status == "failed"
202+
203+
204+
def test_mark_batch_failed_cancels_any_remaining_running_children():
205+
request = BudgetWindowBatchRequest(
206+
country="us",
207+
region="us",
208+
start_year="2026",
209+
window_size=2,
210+
)
211+
212+
state = create_initial_batch_state(
213+
batch_job_id="fc-parent-123",
214+
request=request,
215+
resolved_version="1.500.0",
216+
resolved_app_name="policyengine-simulation-us1-500-0-uk2-66-0",
217+
bundle=PolicyEngineBundle(model_version="1.500.0"),
218+
)
219+
220+
mark_batch_running(state)
221+
mark_child_started(state, year="2026", child_job_id="child-2026")
222+
mark_child_started(state, year="2027", child_job_id="child-2027")
223+
mark_child_failed(state, year="2026", error="boom")
224+
mark_batch_failed(state, error="boom")
225+
226+
assert state.status == "failed"
227+
assert state.running_years == []
228+
assert state.failed_years == ["2026"]
229+
assert state.child_jobs["2026"].status == "failed"
230+
assert state.child_jobs["2027"].status == "cancelled"
231+
assert state.child_jobs["2027"].error == "boom"

projects/policyengine-api-simulation/tests/test_budget_window_batch.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,23 @@ def test_extract_budget_window_annual_impact_matches_v1_shape():
226226
)
227227

228228

229+
def test_extract_budget_window_annual_impact_rejects_malformed_child_result():
230+
with pytest.raises(
231+
ValueError,
232+
match="Malformed budget-window child result: missing numeric budget.tax_revenue_impact",
233+
):
234+
extract_budget_window_annual_impact(
235+
year="2026",
236+
impact_data={
237+
"budget": {
238+
"state_tax_revenue_impact": 40,
239+
"benefit_spending_impact": 20,
240+
"budgetary_impact": 80,
241+
}
242+
},
243+
)
244+
245+
229246
def test_build_budget_window_output_sums_totals():
230247
annual_impacts = [
231248
BudgetWindowAnnualImpact(
@@ -355,6 +372,51 @@ def test_run_budget_window_batch_impl_marks_failure(mock_batch_modal):
355372
assert result["status"] == "failed"
356373
assert result["failed_years"] == ["2026"]
357374
assert result["error"] == "child failed"
375+
assert result["running_years"] == []
376+
assert result["child_jobs"]["2027"]["status"] == "cancelled"
377+
assert result["child_jobs"]["2027"]["error"] == "child failed"
358378
assert state is not None
359379
assert state.status == "failed"
360380
assert state.error == "child failed"
381+
assert state.running_years == []
382+
assert state.child_jobs["2027"].status == "cancelled"
383+
384+
385+
def test_run_budget_window_batch_impl_fails_on_malformed_child_result(
386+
mock_batch_modal,
387+
):
388+
request, payload = _build_parent_payload(window_size=1)
389+
_seed_parent_batch(request, mock_batch_modal["parent_call_id"])
390+
391+
tracker = SpawnTracker()
392+
run_simulation = MockRunSimulationFunction(
393+
tracker=tracker,
394+
results_by_year={
395+
"2026": [
396+
{
397+
"budget": {
398+
"state_tax_revenue_impact": 3,
399+
"benefit_spending_impact": 6,
400+
"budgetary_impact": 17,
401+
}
402+
}
403+
],
404+
},
405+
call_registry=mock_batch_modal["call_registry"],
406+
)
407+
mock_batch_modal["functions"][
408+
("policyengine-simulation-us1-500-0-uk2-66-0", "run_simulation")
409+
] = run_simulation
410+
411+
result = run_budget_window_batch_impl(payload)
412+
state = state_module.get_batch_job_state(mock_batch_modal["parent_call_id"])
413+
414+
assert result["status"] == "failed"
415+
assert result["failed_years"] == ["2026"]
416+
assert (
417+
result["error"]
418+
== "Malformed budget-window child result: missing numeric budget.tax_revenue_impact"
419+
)
420+
assert state is not None
421+
assert state.status == "failed"
422+
assert state.child_jobs["2026"].status == "failed"

0 commit comments

Comments
 (0)