|
11 | 11 | The scheduler operates exclusively on scheduler-owned types (JobRequirements, |
12 | 12 | WorkerCapacity, SchedulingContext) and has ZERO runtime imports from controller |
13 | 13 | state. Callers project worker rows into ``WorkerSnapshot`` (via |
14 | | -``worker_snapshot_from_row``) at the boundary before invoking |
15 | | -``create_scheduling_context``. |
| 14 | +``worker_snapshot_from_row``) at the boundary before constructing |
| 15 | +``SchedulingContext`` directly. |
16 | 16 |
|
17 | 17 | """ |
18 | 18 |
|
@@ -328,100 +328,96 @@ class SchedulingContext: |
328 | 328 | gates/order pipeline and derived structures (``index``, ``capacities``) for |
329 | 329 | the per-(task, worker) matching hot loop. |
330 | 330 |
|
| 331 | + Construction is direct: callers supply ``workers``, ``building_counts``, |
| 332 | + ``max_building_tasks``, and the raw-read fields; ``__post_init__`` derives |
| 333 | + ``capacities``, ``index``, and ``_str_to_wid`` once. To rebuild the index |
| 334 | + after taint injection mid-tick, use :meth:`evolve_with_workers` which reuses |
| 335 | + the raw-read fields and only redoes the per-worker derivation. |
| 336 | +
|
331 | 337 | Posting lists are read-only after construction; capacity deductions don't |
332 | 338 | touch them. Workers are tracked via ``assignment_counts`` to bound tasks |
333 | 339 | per worker per cycle. |
334 | 340 | """ |
335 | 341 |
|
336 | | - index: ConstraintIndex |
337 | | - |
338 | | - # Worker capacities indexed by worker ID |
339 | | - capacities: dict[WorkerId, WorkerCapacity] |
340 | | - |
341 | | - # Reverse map from string ID back to WorkerId |
342 | | - _str_to_wid: dict[str, WorkerId] |
343 | | - |
344 | | - assignment_counts: dict[WorkerId, int] = field(default_factory=dict) |
345 | | - max_assignments_per_worker: int = DEFAULT_MAX_ASSIGNMENTS_PER_WORKER |
346 | | - |
347 | | - # Task IDs in scheduling priority order; populated after gates+order resolve. |
348 | | - pending_tasks: list[JobName] = field(default_factory=list) |
349 | | - jobs: dict[JobName, JobRequirements] = field(default_factory=dict) |
350 | | - |
351 | | - # Raw per-tick reads — consumed by gates/order helpers; empty for diagnostics/dry-run. |
352 | | - pending_task_rows: list[PendingTask] = field(default_factory=list) |
353 | | - workers: list[WorkerSnapshot] = field(default_factory=list) |
354 | | - user_spend: dict[str, int] = field(default_factory=dict) |
355 | | - user_budget_limits: dict[str, int] = field(default_factory=dict) |
356 | | - requested_bands: dict[JobName, int] = field(default_factory=dict) |
357 | | - reserved_job_ids: frozenset[JobName] = field(default_factory=frozenset) |
358 | | - reservation_entry_counts: dict[JobName, int] = field(default_factory=dict) |
359 | | - user_budget_defaults: UserBudgetDefaults | None = None |
| 342 | + workers: list[WorkerSnapshot] |
| 343 | + building_counts: dict[WorkerId, int] |
| 344 | + max_building_tasks: int |
| 345 | + max_assignments_per_worker: int |
| 346 | + pending_tasks: list[JobName] |
| 347 | + jobs: dict[JobName, JobRequirements] |
| 348 | + pending_task_rows: list[PendingTask] |
| 349 | + user_spend: dict[str, int] |
| 350 | + user_budget_limits: dict[str, int] |
| 351 | + requested_bands: dict[JobName, int] |
| 352 | + reserved_job_ids: frozenset[JobName] |
| 353 | + reservation_entry_counts: dict[JobName, int] |
| 354 | + user_budget_defaults: UserBudgetDefaults |
| 355 | + |
| 356 | + # Derived from ``workers`` in __post_init__. |
| 357 | + capacities: dict[WorkerId, WorkerCapacity] = field(init=False) |
| 358 | + index: ConstraintIndex = field(init=False) |
| 359 | + _str_to_wid: dict[str, WorkerId] = field(init=False) |
| 360 | + |
| 361 | + # Per-cycle mutable state; always starts empty. |
| 362 | + assignment_counts: dict[WorkerId, int] = field(init=False) |
360 | 363 |
|
361 | 364 | # Scores memoized per (worker, soft-constraints) tuple; worker attributes are |
362 | 365 | # stable within a tick so the same pair always yields the same score. |
363 | | - _soft_score_cache: dict[tuple[WorkerId, tuple[Constraint, ...]], int] = field(default_factory=dict) |
364 | | - |
365 | | - @property |
366 | | - def all_worker_ids(self) -> set[WorkerId]: |
367 | | - return {self._str_to_wid[s] for s in self.index._all_ids} |
| 366 | + _soft_score_cache: dict[tuple[WorkerId, tuple[Constraint, ...]], int] = field(init=False) |
368 | 367 |
|
369 | | - @classmethod |
370 | | - def from_workers( |
371 | | - cls, |
372 | | - workers: list[WorkerSnapshot], |
373 | | - building_counts: dict[WorkerId, int] | None = None, |
374 | | - max_building_tasks: int = DEFAULT_MAX_BUILDING_TASKS_PER_WORKER, |
375 | | - pending_tasks: list[JobName] | None = None, |
376 | | - jobs: dict[JobName, JobRequirements] | None = None, |
377 | | - max_assignments_per_worker: int = DEFAULT_MAX_ASSIGNMENTS_PER_WORKER, |
378 | | - pending_task_rows: list[PendingTask] | None = None, |
379 | | - user_spend: dict[str, int] | None = None, |
380 | | - user_budget_limits: dict[str, int] | None = None, |
381 | | - requested_bands: dict[JobName, int] | None = None, |
382 | | - reserved_job_ids: frozenset[JobName] | None = None, |
383 | | - reservation_entry_counts: dict[JobName, int] | None = None, |
384 | | - user_budget_defaults: UserBudgetDefaults | None = None, |
385 | | - ) -> "SchedulingContext": |
386 | | - """Build scheduling context from worker list. |
387 | | -
|
388 | | - Creates capacity snapshots and a ConstraintIndex for fast attribute matching. |
389 | | - """ |
390 | | - building_counts = building_counts or {} |
391 | | - |
392 | | - capacities = { |
| 368 | + def __post_init__(self) -> None: |
| 369 | + self.capacities = { |
393 | 370 | w.worker_id: WorkerCapacity.from_worker( |
394 | 371 | w, |
395 | | - building_count=building_counts.get(w.worker_id, 0), |
396 | | - max_building_tasks=max_building_tasks, |
| 372 | + building_count=self.building_counts.get(w.worker_id, 0), |
| 373 | + max_building_tasks=self.max_building_tasks, |
397 | 374 | ) |
398 | | - for w in workers |
| 375 | + for w in self.workers |
399 | 376 | } |
400 | | - |
401 | 377 | str_to_wid: dict[str, WorkerId] = {} |
402 | 378 | entity_attrs: dict[str, dict[str, AttributeValue]] = {} |
403 | | - for wid, cap in capacities.items(): |
| 379 | + for wid, cap in self.capacities.items(): |
404 | 380 | key = str(wid) |
405 | 381 | str_to_wid[key] = wid |
406 | 382 | entity_attrs[key] = dict(cap.attributes) |
| 383 | + self._str_to_wid = str_to_wid |
| 384 | + self.index = ConstraintIndex.build(entity_attrs) |
| 385 | + self.assignment_counts = {} |
| 386 | + self._soft_score_cache = {} |
407 | 387 |
|
408 | | - index = ConstraintIndex.build(entity_attrs) |
409 | | - |
410 | | - return cls( |
411 | | - index=index, |
412 | | - capacities=capacities, |
413 | | - _str_to_wid=str_to_wid, |
414 | | - pending_tasks=pending_tasks or [], |
415 | | - jobs=jobs or {}, |
416 | | - max_assignments_per_worker=max_assignments_per_worker, |
417 | | - pending_task_rows=list(pending_task_rows or []), |
418 | | - workers=list(workers), |
419 | | - user_spend=dict(user_spend or {}), |
420 | | - user_budget_limits=dict(user_budget_limits or {}), |
421 | | - requested_bands=dict(requested_bands or {}), |
422 | | - reserved_job_ids=frozenset(reserved_job_ids or ()), |
423 | | - reservation_entry_counts=dict(reservation_entry_counts or {}), |
424 | | - user_budget_defaults=user_budget_defaults, |
| 388 | + @property |
| 389 | + def all_worker_ids(self) -> set[WorkerId]: |
| 390 | + return {self._str_to_wid[s] for s in self.index._all_ids} |
| 391 | + |
| 392 | + def evolve_with_workers( |
| 393 | + self, |
| 394 | + workers: list[WorkerSnapshot], |
| 395 | + jobs: dict[JobName, JobRequirements], |
| 396 | + building_counts: dict[WorkerId, int], |
| 397 | + max_building_tasks: int, |
| 398 | + ) -> "SchedulingContext": |
| 399 | + """Rebuild capacities/index for taint-injected workers. |
| 400 | +
|
| 401 | + Reuses all raw-read fields (``pending_task_rows``, ``user_spend``, etc.) |
| 402 | + verbatim. The caller supplies updated ``workers``/``jobs`` (e.g. after |
| 403 | + reservation taint injection) and fresh ``building_counts``. The |
| 404 | + returned context starts a fresh placement pass with empty |
| 405 | + ``assignment_counts`` and an empty soft-score cache. |
| 406 | + """ |
| 407 | + return SchedulingContext( |
| 408 | + workers=workers, |
| 409 | + building_counts=building_counts, |
| 410 | + max_building_tasks=max_building_tasks, |
| 411 | + max_assignments_per_worker=self.max_assignments_per_worker, |
| 412 | + pending_tasks=self.pending_tasks, |
| 413 | + jobs=jobs, |
| 414 | + pending_task_rows=self.pending_task_rows, |
| 415 | + user_spend=self.user_spend, |
| 416 | + user_budget_limits=self.user_budget_limits, |
| 417 | + requested_bands=self.requested_bands, |
| 418 | + reserved_job_ids=self.reserved_job_ids, |
| 419 | + reservation_entry_counts=self.reservation_entry_counts, |
| 420 | + user_budget_defaults=self.user_budget_defaults, |
425 | 421 | ) |
426 | 422 |
|
427 | 423 | def matching_workers(self, constraints: Sequence[Constraint]) -> set[WorkerId]: |
@@ -618,6 +614,11 @@ def __init__( |
618 | 614 | ): |
619 | 615 | self._max_building_tasks_per_worker = max_building_tasks_per_worker |
620 | 616 |
|
| 617 | + @property |
| 618 | + def max_building_tasks_per_worker(self) -> int: |
| 619 | + """Per-worker BUILDING-state limit applied to fresh scheduling contexts.""" |
| 620 | + return self._max_building_tasks_per_worker |
| 621 | + |
621 | 622 | def find_assignments( |
622 | 623 | self, |
623 | 624 | context: SchedulingContext, |
@@ -816,35 +817,6 @@ def _group_soft_score(group_worker_ids: list[WorkerId]) -> int: |
816 | 817 | ) |
817 | 818 | return None |
818 | 819 |
|
819 | | - def create_scheduling_context( |
820 | | - self, |
821 | | - workers: list[WorkerSnapshot], |
822 | | - building_counts: dict[WorkerId, int] | None = None, |
823 | | - pending_tasks: list[JobName] | None = None, |
824 | | - jobs: dict[JobName, JobRequirements] | None = None, |
825 | | - max_building_tasks: int | None = None, |
826 | | - max_assignments_per_worker: int | None = None, |
827 | | - ) -> SchedulingContext: |
828 | | - """Create a scheduling context for the given workers. |
829 | | -
|
830 | | - Convenience wrapper for tests, diagnostics, and the autoscaler dry-run |
831 | | - path. Does not populate the raw read fields (``pending_task_rows``, |
832 | | - ``user_spend``, etc.); use ``build_scheduling_context`` for the full |
833 | | - scheduling-loop construction. |
834 | | - """ |
835 | | - limit = max_building_tasks if max_building_tasks is not None else self._max_building_tasks_per_worker |
836 | | - assignments_limit = ( |
837 | | - max_assignments_per_worker if max_assignments_per_worker is not None else DEFAULT_MAX_ASSIGNMENTS_PER_WORKER |
838 | | - ) |
839 | | - return SchedulingContext.from_workers( |
840 | | - workers, |
841 | | - building_counts=building_counts, |
842 | | - max_building_tasks=limit, |
843 | | - pending_tasks=pending_tasks, |
844 | | - jobs=jobs, |
845 | | - max_assignments_per_worker=assignments_limit, |
846 | | - ) |
847 | | - |
848 | 820 | def get_job_scheduling_diagnostics( |
849 | 821 | self, |
850 | 822 | req: JobRequirements, |
|
0 commit comments