Skip to content

Commit 86ec152

Browse files
committed
Implement budget-window batch orchestration
1 parent 2ef0ea2 commit 86ec152

8 files changed

Lines changed: 1089 additions & 31 deletions

File tree

projects/policyengine-api-simulation/fixtures/gateway/test_endpoints.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,29 @@ def __init__(self):
5959
self.last_payload = None
6060
self.last_from_name_call = None
6161
self.last_call = None
62+
self.calls = []
6263

63-
def spawn(self, payload: dict) -> MockFunctionCall:
64-
self.last_payload = payload
65-
self.last_call = MockFunctionCall()
66-
return self.last_call
64+
def bind(self, app_name: str, func_name: str) -> "BoundMockFunction":
65+
return BoundMockFunction(self, app_name, func_name)
6766

68-
@classmethod
69-
def from_name(cls, app_name: str, func_name: str):
70-
"""Mock from_name that returns a MockFunction."""
71-
raise NotImplementedError("Mock not configured")
67+
68+
class BoundMockFunction:
69+
"""Function handle returned by Modal.Function.from_name."""
70+
71+
def __init__(self, recorder: MockFunction, app_name: str, func_name: str):
72+
self.recorder = recorder
73+
self.app_name = app_name
74+
self.func_name = func_name
75+
76+
def spawn(self, payload: dict) -> MockFunctionCall:
77+
self.recorder.last_payload = payload
78+
is_batch = self.func_name == "run_budget_window_batch"
79+
object_id = "mock-batch-job-id-123" if is_batch else "mock-job-id-123"
80+
self.recorder.last_call = MockFunctionCall(object_id=object_id)
81+
if is_batch:
82+
self.recorder.last_call.running = True
83+
self.recorder.calls.append((self.app_name, self.func_name, payload, object_id))
84+
return self.recorder.last_call
7285

7386

7487
@pytest.fixture
@@ -94,7 +107,7 @@ class MockModalFunction:
94107
@staticmethod
95108
def from_name(app_name: str, func_name: str):
96109
mock_func.last_from_name_call = (app_name, func_name)
97-
return mock_func
110+
return mock_func.bind(app_name, func_name)
98111

99112
class MockModal:
100113
Dict = MockModalDict

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,32 @@ def run_simulation(params: dict) -> dict:
104104
return result
105105
finally:
106106
logfire.force_flush()
107+
108+
109+
@app.function(
110+
image=simulation_image,
111+
cpu=1.0,
112+
memory=4096,
113+
timeout=3600,
114+
retries=0,
115+
max_containers=100,
116+
secrets=[gcp_secret, logfire_secret],
117+
)
118+
def run_budget_window_batch(params: dict) -> dict:
119+
"""Execute a multi-year budget-window batch orchestration."""
120+
import logfire
121+
122+
from src.modal.budget_window_batch import run_budget_window_batch_impl
123+
124+
configure_logfire()
125+
126+
try:
127+
with logfire.span(
128+
"run_budget_window_batch",
129+
input_params=params,
130+
) as span:
131+
result = run_budget_window_batch_impl(params)
132+
span.set_attribute("output_result", result)
133+
return result
134+
finally:
135+
logfire.force_flush()
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Budget-window batch orchestration for versioned simulation apps."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
from typing import Any
7+
8+
import modal
9+
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+
33+
34+
def _extract_request_and_metadata(
35+
params: dict[str, Any],
36+
) -> tuple[BudgetWindowBatchRequest, str, str, PolicyEngineBundle]:
37+
request = BudgetWindowBatchRequest.model_validate(params)
38+
metadata = params.get("_metadata")
39+
if not isinstance(metadata, dict):
40+
raise ValueError("Missing internal batch metadata")
41+
42+
resolved_app_name = metadata.get("resolved_app_name")
43+
resolved_version = metadata.get("resolved_version")
44+
bundle_payload = metadata.get("policyengine_bundle")
45+
46+
if not isinstance(resolved_app_name, str) or not resolved_app_name:
47+
raise ValueError("Missing resolved_app_name in batch metadata")
48+
if not isinstance(resolved_version, str) or not resolved_version:
49+
raise ValueError("Missing resolved_version in batch metadata")
50+
if not isinstance(bundle_payload, dict):
51+
raise ValueError("Missing policyengine_bundle in batch metadata")
52+
53+
return (
54+
request,
55+
resolved_version,
56+
resolved_app_name,
57+
PolicyEngineBundle.model_validate(bundle_payload),
58+
)
59+
60+
61+
def build_child_simulation_payload(
62+
params: dict[str, Any], *, year: str
63+
) -> dict[str, Any]:
64+
payload = {
65+
key: value
66+
for key, value in params.items()
67+
if key
68+
not in {
69+
"version",
70+
"start_year",
71+
"window_size",
72+
"max_parallel",
73+
"_metadata",
74+
"_telemetry",
75+
}
76+
}
77+
payload["time_period"] = year
78+
79+
telemetry = params.get("_telemetry")
80+
if isinstance(telemetry, dict):
81+
payload["_telemetry"] = telemetry
82+
83+
return payload
84+
85+
86+
def extract_budget_window_annual_impact(
87+
*, year: str, impact_data: dict[str, Any]
88+
) -> BudgetWindowAnnualImpact:
89+
budget = impact_data.get("budget", {})
90+
state_tax_revenue_impact = budget.get("state_tax_revenue_impact", 0)
91+
tax_revenue_impact = budget.get("tax_revenue_impact", 0)
92+
93+
return BudgetWindowAnnualImpact(
94+
year=year,
95+
taxRevenueImpact=tax_revenue_impact,
96+
federalTaxRevenueImpact=tax_revenue_impact - state_tax_revenue_impact,
97+
stateTaxRevenueImpact=state_tax_revenue_impact,
98+
benefitSpendingImpact=budget.get("benefit_spending_impact", 0),
99+
budgetaryImpact=budget.get("budgetary_impact", 0),
100+
)
101+
102+
103+
def sum_budget_window_annual_impacts(
104+
annual_impacts: list[BudgetWindowAnnualImpact],
105+
) -> BudgetWindowTotals:
106+
totals = {
107+
"taxRevenueImpact": 0,
108+
"federalTaxRevenueImpact": 0,
109+
"stateTaxRevenueImpact": 0,
110+
"benefitSpendingImpact": 0,
111+
"budgetaryImpact": 0,
112+
}
113+
114+
for annual_impact in annual_impacts:
115+
totals["taxRevenueImpact"] += annual_impact.taxRevenueImpact
116+
totals["federalTaxRevenueImpact"] += annual_impact.federalTaxRevenueImpact
117+
totals["stateTaxRevenueImpact"] += annual_impact.stateTaxRevenueImpact
118+
totals["benefitSpendingImpact"] += annual_impact.benefitSpendingImpact
119+
totals["budgetaryImpact"] += annual_impact.budgetaryImpact
120+
121+
return BudgetWindowTotals(**totals)
122+
123+
124+
def build_budget_window_output(
125+
*,
126+
start_year: str,
127+
window_size: int,
128+
annual_impacts: list[BudgetWindowAnnualImpact],
129+
) -> BudgetWindowResult:
130+
return BudgetWindowResult(
131+
startYear=start_year,
132+
endYear=str(int(start_year) + window_size - 1),
133+
windowSize=window_size,
134+
annualImpacts=annual_impacts,
135+
totals=sum_budget_window_annual_impacts(annual_impacts),
136+
)
137+
138+
139+
def _failure_response(state) -> dict[str, Any]:
140+
return build_batch_status_response(state).model_dump(mode="json")
141+
142+
143+
def run_budget_window_batch_impl(params: dict[str, Any]) -> dict[str, Any]:
144+
batch_job_id = modal.current_function_call_id()
145+
request, resolved_version, resolved_app_name, bundle = (
146+
_extract_request_and_metadata(params)
147+
)
148+
149+
state = get_batch_job_seed(batch_job_id)
150+
if state is None:
151+
state = create_initial_batch_state(
152+
batch_job_id=batch_job_id,
153+
request=request,
154+
resolved_version=resolved_version,
155+
resolved_app_name=resolved_app_name,
156+
bundle=bundle,
157+
)
158+
put_batch_job_seed(state)
159+
160+
mark_batch_running(state)
161+
put_batch_job_state(state)
162+
163+
child_func = modal.Function.from_name(resolved_app_name, "run_simulation")
164+
child_calls: dict[str, Any] = {}
165+
166+
while state.queued_years or state.running_years:
167+
while len(state.running_years) < state.max_parallel and state.queued_years:
168+
year = state.queued_years[0]
169+
child_payload = build_child_simulation_payload(params, year=year)
170+
call = child_func.spawn(child_payload)
171+
child_calls[year] = call
172+
mark_child_started(state, year=year, child_job_id=call.object_id)
173+
put_batch_job_state(state)
174+
175+
progress_made = False
176+
for year in list(state.running_years):
177+
call = child_calls.get(year)
178+
if call is None:
179+
call = modal.FunctionCall.from_id(state.child_jobs[year].job_id)
180+
child_calls[year] = call
181+
182+
try:
183+
result = call.get(timeout=0)
184+
except TimeoutError:
185+
continue
186+
except Exception as exc:
187+
error = str(exc)
188+
mark_child_failed(state, year=year, error=error)
189+
mark_batch_failed(state, error=error)
190+
put_batch_job_state(state)
191+
return _failure_response(state)
192+
193+
annual_impact = extract_budget_window_annual_impact(
194+
year=year,
195+
impact_data=result,
196+
)
197+
mark_child_completed(state, year=year, annual_impact=annual_impact)
198+
put_batch_job_state(state)
199+
progress_made = True
200+
201+
if state.running_years and not progress_made:
202+
time.sleep(POLL_INTERVAL_SECONDS)
203+
204+
annual_impacts = [
205+
state.partial_annual_impacts[year]
206+
for year in state.years
207+
if year in state.partial_annual_impacts
208+
]
209+
result = build_budget_window_output(
210+
start_year=state.start_year,
211+
window_size=state.window_size,
212+
annual_impacts=annual_impacts,
213+
)
214+
mark_batch_complete(state, result=result)
215+
put_batch_job_state(state)
216+
217+
response = build_batch_status_response(state)
218+
return response.model_dump(mode="json")

0 commit comments

Comments
 (0)