Skip to content

feat!: probe-based claim gate — replace model_filters with leaf-probe health - #1497

Draft
rushilbhat wants to merge 2 commits into
mainfrom
feat/probe-based-claim-gate
Draft

feat!: probe-based claim gate — replace model_filters with leaf-probe health#1497
rushilbhat wants to merge 2 commits into
mainfrom
feat/probe-based-claim-gate

Conversation

@rushilbhat

Copy link
Copy Markdown
Contributor

What

COR-611 part A, in two commits:

  1. feat(fusillade)! — the claim gate's state source becomes an injected per-model map (ModelGateState::{Open, Throttled}, shared Arc<DashMap> on DaemonConfig, mirroring model_concurrency_limits). All claim paths (batchless Source A/B, batch, background) consume a model_gates CTE from aligned arrays instead of joining model_filters. The throttle machinery — leaky-bucket trickle, deadline ramp, cooldowns — is untouched; absent from the map = Open, so nothing can be strangled by missing state. batch_claim_require_livebatch_claim_require_open (serde alias kept). The model_filters table and APIs remain (deprecated; scouter#158 stops writing them).
  2. feat(dwctl) — populates the map from real health:
    • Probe auto-creation: every leaf catalog entry (non-composite, non-deleted, has an endpoint) gets an auto-<alias> probe via one idempotent insert, run at sync startup + every catalog full_reload. Manual probes are never clobbered; nothing is ever deleted. Flag: probes.auto_create (default true). The existing (currently dormant) probe scheduler executes them through the local /ai proxy — the exact path dispatched work takes.
    • Gate computation: a leaf is healthy unless its most recent probe result within 3×interval is a failure (no probe / no recent result = healthy). Composites resolve through the primary enabled component (sort_order, weight, created_at; depth-bounded). The 15s refresh keeps the shared map sparse — it only ever names Throttled models — and logs transitions only.

Why

The 2026-08-14 incident (COR-610): fusillade obeyed a scouter-written liveness note that could never be updated after a model rename, silently throttling a customer's queue to ~1 req/min for four hours with zero errors. Full design rationale in COR-611 — the gate now asks a question the control layer can answer itself, per-model, through the real serving path, with no cross-system name coupling and no stored state that a rename can strand. Bonus: a dead external provider now holds its queue instead of blind-firing into the outage.

Sequencing

Land and deploy before scouter#158 (which stops writing model_filters) — this PR is what replaces the protection that removal takes away. Independent of #1496 (demand fold).

Tests

Full just test rust green (dwctl lib 2,047; fusillade-arsenal 214; all suites exit 0). COR-432 gate tests adapted to the injected map (incl. two repurposed: gate ignores model_filters events; absent-from-map claims full capacity). 14 new dwctl tests: auto-creation idempotency/skips/no-clobber, health-window semantics (recent failure throttles → reopens; stale failure is healthy; recent failure beats older success), composite resolution (primary, disabled-primary skip, recursion bound, dead-end open), sparse-map invariant. Known local-only sqlx prepare --check mismatch on pre-existing query-aabf6640… (PG17 drift, present on main).

…ate map

Replace the claim gate's state SOURCE: the batchless, batch, and
background claim queries no longer consult the model_filters event log.
Each claim now receives a per-model gate map (ModelGateState: open /
throttled), marshalled as two aligned TEXT[] arrays into a
model_gates(model, state) CTE. The throttle MACHINERY is unchanged:
the deadline-ramp escape hatch, the one-per-(user, window-class, model)
leaky-bucket trickle, and cooldowns behave exactly as before, with
"throttled" taking the role of not-live.

- fusillade-core: new serializable enum ModelGateState with as_str() ->
  "open"/"throttled". Open claims at full capacity and is the default
  for models absent from the map -- absence must never strangle.
- Storage trait: claim_batchless_requests / claim_requests /
  claim_batch_requests and the background-claim family now take
  gate_states: &HashMap<String, ModelGateState>.
- DaemonConfig: new model_gate_states: Arc<DashMap<String,
  ModelGateState>> mirroring model_concurrency_limits (same serde
  treatment, default empty = everything open); the claim loops snapshot
  it each cycle next to available_capacity. dwctl populates the map in
  the next commit -- until then it is empty and the gate is a no-op.
- Rename batch_claim_require_live -> batch_claim_require_open (serde
  alias keeps the old config key working) across dwctl config,
  fusillade DaemonConfig, and PostgresStorageConfig.
- Background-claim semantic change: previously strictly live-only
  (INNER JOIN requiring the latest model_filters event = 'live'); now a
  model absent from the gate map is treated as open, per the
  absence-is-open invariant, so background claiming no longer requires
  an explicit signal.
- The model_filters table and its append/list/purge/
  current_filter_states APIs remain in place (deprecated as a claim
  source); scouter stops writing the log in a separate change.
…obe health

Populate the per-model claim-gate map the previous commit injected into
the fusillade daemon.

- Probe auto-creation: reconcile_auto_probes inserts an 'auto-<alias>'
  probe (60s interval, active, POST) for every non-deleted,
  non-composite deployed model with a hosting endpoint, via a single
  INSERT ... SELECT ... ON CONFLICT DO NOTHING -- idempotent and
  multi-pod safe. It never touches existing (manual) probes and never
  deletes probes (model deletion cascades instead). Runs on onwards-sync
  startup and on every full_reload, behind the new `probes.auto_create`
  config flag (default: true).

- Health rule per leaf entry: healthy unless the most recent
  probe_results row within the last 3 * interval_seconds has
  success = false. No probe, an inactive probe, or no result inside the
  window all count as healthy -- absence of monitoring must never
  strangle a queue, and a result older than the window is stale, not
  damning.

- Gate per catalog alias: a non-composite entry follows its own leaf
  health (healthy -> Open, else Throttled); a composite resolves its
  PRIMARY enabled component (lowest sort_order, tie weight DESC /
  created_at ASC) recursively to a leaf, depth-bounded at 8, and
  follows that leaf. Any resolution dead-end is Open.

- Sparse-map invariant: the updater only inserts Throttled entries and
  evicts everything else, so the shared DashMap only ever names
  throttled models and absent = Open stays the daemon-side default.
  State transitions (throttled / reopened) log at info; steady states
  do not.

- Refresh cadence: a claim-gate refresher task recomputes the map from
  the DB every 15s, spawned under the same batch_daemon.enabled
  (Always | Leader) condition as the fusillade daemon itself; failures
  emit dwctl_background_errors_total{component="claim_gate"}.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying control-layer with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0847bb9
Status: ✅  Deploy successful!
Preview URL: https://bd89ca37.control-layer.pages.dev
Branch Preview URL: https://feat-probe-based-claim-gate.control-layer.pages.dev

View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant