Skip to content

Commit 165c99c

Browse files
committed
Refactor budget-window batch orchestration
1 parent 8987d89 commit 165c99c

7 files changed

Lines changed: 548 additions & 333 deletions

File tree

Lines changed: 8 additions & 235 deletions
Original file line numberDiff line numberDiff line change
@@ -1,246 +1,19 @@
1-
"""Budget-window batch orchestration for versioned simulation apps."""
1+
"""Thin entrypoint for budget-window batch execution."""
22

33
from __future__ import annotations
44

5-
import time
65
from typing import Any
76

87
import modal
98

10-
from src.modal.budget_window_state import (
11-
build_batch_status_response,
12-
create_initial_batch_state,
13-
get_batch_job_seed,
14-
mark_batch_complete,
15-
mark_batch_failed,
16-
mark_batch_running,
17-
mark_child_completed,
18-
mark_child_failed,
19-
mark_child_started,
20-
put_batch_job_seed,
21-
put_batch_job_state,
22-
)
23-
from src.modal.gateway.models import (
24-
BudgetWindowAnnualImpact,
25-
BudgetWindowBatchRequest,
26-
BudgetWindowResult,
27-
BudgetWindowTotals,
28-
PolicyEngineBundle,
29-
)
30-
31-
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-
)
38-
39-
40-
def _extract_request_and_metadata(
41-
params: dict[str, Any],
42-
) -> tuple[BudgetWindowBatchRequest, str, str, PolicyEngineBundle]:
43-
request = BudgetWindowBatchRequest.model_validate(params)
44-
metadata = params.get("_metadata")
45-
if not isinstance(metadata, dict):
46-
raise ValueError("Missing internal batch metadata")
47-
48-
resolved_app_name = metadata.get("resolved_app_name")
49-
resolved_version = metadata.get("resolved_version")
50-
bundle_payload = metadata.get("policyengine_bundle")
51-
52-
if not isinstance(resolved_app_name, str) or not resolved_app_name:
53-
raise ValueError("Missing resolved_app_name in batch metadata")
54-
if not isinstance(resolved_version, str) or not resolved_version:
55-
raise ValueError("Missing resolved_version in batch metadata")
56-
if not isinstance(bundle_payload, dict):
57-
raise ValueError("Missing policyengine_bundle in batch metadata")
58-
59-
return (
60-
request,
61-
resolved_version,
62-
resolved_app_name,
63-
PolicyEngineBundle.model_validate(bundle_payload),
64-
)
65-
66-
67-
def build_child_simulation_payload(
68-
params: dict[str, Any], *, year: str
69-
) -> dict[str, Any]:
70-
payload = {
71-
key: value
72-
for key, value in params.items()
73-
if key
74-
not in {
75-
"version",
76-
"start_year",
77-
"window_size",
78-
"max_parallel",
79-
"_metadata",
80-
"_telemetry",
81-
}
82-
}
83-
payload["time_period"] = year
84-
85-
telemetry = params.get("_telemetry")
86-
if isinstance(telemetry, dict):
87-
payload["_telemetry"] = telemetry
88-
89-
return payload
90-
91-
92-
def extract_budget_window_annual_impact(
93-
*, year: str, impact_data: dict[str, Any]
94-
) -> BudgetWindowAnnualImpact:
95-
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-
110-
state_tax_revenue_impact = budget.get("state_tax_revenue_impact", 0)
111-
tax_revenue_impact = budget.get("tax_revenue_impact", 0)
112-
113-
return BudgetWindowAnnualImpact(
114-
year=year,
115-
taxRevenueImpact=tax_revenue_impact,
116-
federalTaxRevenueImpact=tax_revenue_impact - state_tax_revenue_impact,
117-
stateTaxRevenueImpact=state_tax_revenue_impact,
118-
benefitSpendingImpact=budget.get("benefit_spending_impact", 0),
119-
budgetaryImpact=budget.get("budgetary_impact", 0),
120-
)
121-
122-
123-
def sum_budget_window_annual_impacts(
124-
annual_impacts: list[BudgetWindowAnnualImpact],
125-
) -> BudgetWindowTotals:
126-
totals = {
127-
"taxRevenueImpact": 0,
128-
"federalTaxRevenueImpact": 0,
129-
"stateTaxRevenueImpact": 0,
130-
"benefitSpendingImpact": 0,
131-
"budgetaryImpact": 0,
132-
}
133-
134-
for annual_impact in annual_impacts:
135-
totals["taxRevenueImpact"] += annual_impact.taxRevenueImpact
136-
totals["federalTaxRevenueImpact"] += annual_impact.federalTaxRevenueImpact
137-
totals["stateTaxRevenueImpact"] += annual_impact.stateTaxRevenueImpact
138-
totals["benefitSpendingImpact"] += annual_impact.benefitSpendingImpact
139-
totals["budgetaryImpact"] += annual_impact.budgetaryImpact
140-
141-
return BudgetWindowTotals(**totals)
142-
143-
144-
def build_budget_window_output(
145-
*,
146-
start_year: str,
147-
window_size: int,
148-
annual_impacts: list[BudgetWindowAnnualImpact],
149-
) -> BudgetWindowResult:
150-
return BudgetWindowResult(
151-
startYear=start_year,
152-
endYear=str(int(start_year) + window_size - 1),
153-
windowSize=window_size,
154-
annualImpacts=annual_impacts,
155-
totals=sum_budget_window_annual_impacts(annual_impacts),
156-
)
157-
158-
159-
def _failure_response(state) -> dict[str, Any]:
160-
return build_batch_status_response(state).model_dump(mode="json")
9+
from src.modal.budget_window_context import build_batch_context
10+
from src.modal.budget_window_scheduler import BudgetWindowBatchRunner
16111

16212

16313
def run_budget_window_batch_impl(params: dict[str, Any]) -> dict[str, Any]:
164-
batch_job_id = modal.current_function_call_id()
165-
request, resolved_version, resolved_app_name, bundle = (
166-
_extract_request_and_metadata(params)
14+
context = build_batch_context(
15+
params,
16+
batch_job_id=modal.current_function_call_id(),
16717
)
168-
169-
state = get_batch_job_seed(batch_job_id)
170-
if state is None:
171-
state = create_initial_batch_state(
172-
batch_job_id=batch_job_id,
173-
request=request,
174-
resolved_version=resolved_version,
175-
resolved_app_name=resolved_app_name,
176-
bundle=bundle,
177-
)
178-
put_batch_job_seed(state)
179-
180-
mark_batch_running(state)
181-
put_batch_job_state(state)
182-
183-
child_func = modal.Function.from_name(resolved_app_name, "run_simulation")
184-
child_calls: dict[str, Any] = {}
185-
186-
while state.queued_years or state.running_years:
187-
while len(state.running_years) < state.max_parallel and state.queued_years:
188-
year = state.queued_years[0]
189-
child_payload = build_child_simulation_payload(params, year=year)
190-
call = child_func.spawn(child_payload)
191-
child_calls[year] = call
192-
mark_child_started(state, year=year, child_job_id=call.object_id)
193-
put_batch_job_state(state)
194-
195-
progress_made = False
196-
for year in list(state.running_years):
197-
call = child_calls.get(year)
198-
if call is None:
199-
call = modal.FunctionCall.from_id(state.child_jobs[year].job_id)
200-
child_calls[year] = call
201-
202-
try:
203-
result = call.get(timeout=0)
204-
except TimeoutError:
205-
continue
206-
except Exception as exc:
207-
error = str(exc)
208-
mark_child_failed(state, year=year, error=error)
209-
mark_batch_failed(state, error=error)
210-
put_batch_job_state(state)
211-
return _failure_response(state)
212-
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-
225-
mark_child_completed(state, year=year, annual_impact=annual_impact)
226-
put_batch_job_state(state)
227-
progress_made = True
228-
229-
if state.running_years and not progress_made:
230-
time.sleep(POLL_INTERVAL_SECONDS)
231-
232-
annual_impacts = [
233-
state.partial_annual_impacts[year]
234-
for year in state.years
235-
if year in state.partial_annual_impacts
236-
]
237-
result = build_budget_window_output(
238-
start_year=state.start_year,
239-
window_size=state.window_size,
240-
annual_impacts=annual_impacts,
241-
)
242-
mark_batch_complete(state, result=result)
243-
put_batch_job_state(state)
244-
245-
response = build_batch_status_response(state)
246-
return response.model_dump(mode="json")
18+
runner = BudgetWindowBatchRunner(context)
19+
return runner.run()
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Typed context and child request helpers for budget-window batches."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import Any
7+
8+
from src.modal.gateway.models import BudgetWindowBatchRequest, PolicyEngineBundle
9+
10+
BATCH_ONLY_FIELDS = {
11+
"version",
12+
"start_year",
13+
"window_size",
14+
"max_parallel",
15+
"target",
16+
"_metadata",
17+
"_telemetry",
18+
}
19+
20+
21+
@dataclass(frozen=True)
22+
class BudgetWindowBatchContext:
23+
"""Resolved parent batch execution context."""
24+
25+
batch_job_id: str
26+
request: BudgetWindowBatchRequest
27+
resolved_version: str
28+
resolved_app_name: str
29+
bundle: PolicyEngineBundle
30+
raw_params: dict[str, Any]
31+
32+
33+
@dataclass(frozen=True)
34+
class ChildSimulationRequest:
35+
"""Expanded single-year child simulation request."""
36+
37+
simulation_year: str
38+
payload: dict[str, Any]
39+
40+
41+
@dataclass
42+
class ChildSimulationHandle:
43+
"""Tracked child job handle for a single simulation year."""
44+
45+
simulation_year: str
46+
job_id: str
47+
call: Any | None = None
48+
49+
50+
def build_batch_context(
51+
params: dict[str, Any],
52+
*,
53+
batch_job_id: str,
54+
) -> BudgetWindowBatchContext:
55+
request = BudgetWindowBatchRequest.model_validate(params)
56+
metadata = params.get("_metadata")
57+
if not isinstance(metadata, dict):
58+
raise ValueError("Missing internal batch metadata")
59+
60+
resolved_app_name = metadata.get("resolved_app_name")
61+
resolved_version = metadata.get("resolved_version")
62+
bundle_payload = metadata.get("policyengine_bundle")
63+
64+
if not isinstance(resolved_app_name, str) or not resolved_app_name:
65+
raise ValueError("Missing resolved_app_name in batch metadata")
66+
if not isinstance(resolved_version, str) or not resolved_version:
67+
raise ValueError("Missing resolved_version in batch metadata")
68+
if not isinstance(bundle_payload, dict):
69+
raise ValueError("Missing policyengine_bundle in batch metadata")
70+
71+
return BudgetWindowBatchContext(
72+
batch_job_id=batch_job_id,
73+
request=request,
74+
resolved_version=resolved_version,
75+
resolved_app_name=resolved_app_name,
76+
bundle=PolicyEngineBundle.model_validate(bundle_payload),
77+
raw_params=params,
78+
)
79+
80+
81+
def build_child_simulation_request(
82+
context: BudgetWindowBatchContext,
83+
*,
84+
simulation_year: str,
85+
) -> ChildSimulationRequest:
86+
payload = {
87+
key: value
88+
for key, value in context.raw_params.items()
89+
if key not in BATCH_ONLY_FIELDS
90+
}
91+
payload["time_period"] = simulation_year
92+
93+
telemetry = context.raw_params.get("_telemetry")
94+
if isinstance(telemetry, dict):
95+
payload["_telemetry"] = telemetry
96+
97+
return ChildSimulationRequest(
98+
simulation_year=simulation_year,
99+
payload=payload,
100+
)

0 commit comments

Comments
 (0)