Skip to content

Latest commit

 

History

History
59 lines (51 loc) · 4.03 KB

File metadata and controls

59 lines (51 loc) · 4.03 KB

ADR 0018: Due-work index and fair-share dispatch as pure, testable components

  • Status: Accepted (2026-08-23)
  • Implements: roadmap M4 (foundation); AD4; extends ADR-0005, ADR-0016

Context

ADR-0005 decided a DueWork index replaces the prototype's global EventBridge cadences, scanned by a dispatcher that fans bounded batches into priority-lane queues with per-user concurrency caps. That ADR fixed the shape; this one implements the scanning and fairness logic as dependency-free Python so the fairness property — no noisy neighbor starves anyone else — is unit- and property-testable without SQS, Lambda, or DynamoDB in the loop. Everything downstream (the actual queue technology, the DynamoDB GSI) is an adapter around this logic, not a rewrite of it, matching how InMemoryStore already stands in for the future DynamoDB adapter (ADR-0016).

The due-work index is inherently cross-tenant: a fair dispatcher must see every user's next-due time in one scan. Every other surface in this codebase (TenantRepository) is deny-by-default and single-tenant per call — deliberately incompatible with that requirement. Bolting a scan method onto TenantRepository would have meant either breaking its isolation invariant for everyone or special-casing one caller inside the file the adversarial suite treats as a security boundary (ADR-0016).

Decision

  1. DueWorkIndex is its own storage surface, not a entities.ENTITIES member and not a TenantRepository consumer. Writes (schedule, unschedule, get) still require a validated TenantContext, same as everywhere else in the codebase. Reads for the dispatcher (due_before) are deliberately cross-tenant — the "operator GSI" roadmap Appendix B describes — and documented as the narrow, intentional exception it is. The row shape itself (DueWorkEntry, a fixed dataclass) structurally cannot carry posting content, credentials, or settings, so the exception cannot leak more than scheduling metadata by construction.
  2. Cadence is a pure function (cadence.next_interval_seconds) over a policy and a miss-streak counter: a hit snaps back to the tier's base interval, a miss backs off multiplicatively up to a clamp. Named presets (TIER_HOT/WARM/COLD, DIGEST_WEEKLY) replace poll_tiering.yaml's global constants; due_work.advance wires the function to one entry without any caller touching a clock except by passing now explicitly.
  3. Dispatch is round-robin-fair per lane, priority-ordered across lanes. Within one lane (free/plus/pro), _drain_lane_fair grants each distinct user a turn before any user gets a second item, so a cap smaller than one user's backlog still reaches everyone else that cycle. Across lanes, a global_batch_limit (the real constraint: one dispatcher invocation's Lambda budget) prefers pro, then plus, then free — but only after each lane has already applied its own per-user fairness, so paying more buys priority, never someone else's fair share within a cheaper lane.
  4. Shedding is visible, not silent. DispatchBatch.shed_user_ids names every user who had due work this cycle but didn't get all of it dispatched — whether capped by their own lane's per-user limit or dropped by the global cap. Nothing is deleted; shed entries simply remain due and are picked up next cycle.

Consequences

  • The DynamoDB GSI adapter for DueWorkIndex (M4 infra work) only needs to reproduce due_before's ordering contract; the fairness algorithm above does not change.
  • Dispatcher takes lane_for as a plain callable rather than importing control.plans directly, so scheduling/ stays decoupled from the control plane; production wiring is plan_lane(entitlements.plan(ctx).plan_id).
  • A future queue-depth or worker-health signal can feed LaneCapacity.per_cycle_limit per cycle without touching the fairness algorithm.
  • shed_user_ids is the hook for the noisy-neighbor alarm the roadmap's cost-safety workstream calls for; nothing further is needed to observe it, only to alarm on it.