|
| 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