|
1 | | -"""Budget-window batch orchestration for versioned simulation apps.""" |
| 1 | +"""Thin entrypoint for budget-window batch execution.""" |
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | | -import time |
6 | 5 | from typing import Any |
7 | 6 |
|
8 | 7 | import modal |
9 | 8 |
|
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 |
161 | 11 |
|
162 | 12 |
|
163 | 13 | 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(), |
167 | 17 | ) |
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() |
0 commit comments