Skip to content

Commit bb3b4a2

Browse files
committed
Phase 2 and 3: true event-driven watch, a ScheduledMachine.spec.spotSchedule reference now actually drives the machine's active/inactive decision (pull-on-reconcile)
Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent 605baab commit bb3b4a2

21 files changed

Lines changed: 2201 additions & 61 deletions

.claude/CHANGELOG.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,106 @@ The format is based on the regulated environment requirements:
99

1010
---
1111

12+
## [2026-06-14 11:00] - Spot-schedule dynamic event-driven provider watch (Phase 3)
13+
14+
**Author:** Erick Bourgeois
15+
16+
### Changed
17+
- `src/reconcilers/spot_schedule_watch.rs`: NEW. `ReverseIndex` (pure:
18+
`(gvk, ns, name)` → referencing `ScheduledMachine`s, with per-SM record so a
19+
changed/removed `spec.spotSchedule` updates precisely) + `provider_key_for`.
20+
`SpotScheduleWatchManager` owns the index and lazily runs **one
21+
`kube::runtime::watcher` stream per referenced provider GVK** (event-driven,
22+
not polling); `sync_watchers` reconciles the running watcher set against the
23+
index's referenced GVKs (spawn new, abort orphaned). Per-GVK task resolves the
24+
`ApiResource` via `discovery::pinned_kind` with back-off
25+
(`SPOT_SCHEDULE_DISCOVERY_RETRY_SECS`, handles CRD-installed-late and
26+
CRD-deleted-while-watched), maps each provider event back through the index,
27+
and enqueues the referencing SMs.
28+
- `src/main.rs`: a standalone `ScheduledMachine` reflector feeds SM apply/delete
29+
into the manager (index rebuilt from the reflector's initial list on restart —
30+
nothing stored outside Kubernetes); a second `Controller::reconcile_on`
31+
consumes the manager's mpsc so provider `status.active` flips trigger SM
32+
reconciles at watch latency.
33+
- `src/constants.rs`: `SPOT_SCHEDULE_EVENT_CHANNEL_CAP`,
34+
`SPOT_SCHEDULE_DISCOVERY_RETRY_SECS`.
35+
- `src/reconcilers/mod.rs`: export the watch types + `compose_should_be_active`.
36+
- Tests: `spot_schedule_watch_tests.rs` (13 — ReverseIndex register/replace/
37+
deregister/lookup/gvks, `provider_key_for`, manager observe/forget watcher
38+
lifecycle incl. shared-GVK and watcher-stop-on-last-deref);
39+
`tests/integration_spot_schedule.rs` (4 — create→active, flip→inactive,
40+
delete→unresolved+hold/fail-inactive across a mock kube API, and the watch
41+
index→reconcile mapping). 658 lib tests green.
42+
- Docs: contract page status → Phase 3.
43+
44+
### Why
45+
Phase 3 of the spot-schedule provider roadmap: remove the Phase-2 pull latency.
46+
Provider status transitions are now a reconcile **trigger** (a true watch over
47+
the `spotschedules.5spot.finos.org` group), not something re-checked only on the
48+
controller's own timer — satisfying the project's watch-not-poll rule. Realizes
49+
the `rel-controller-spot-schedule-watch` relationship + `flow-spot-schedule-activation`
50+
already modeled in CALM at Phase 0 (no ADR/CALM change).
51+
52+
### Impact
53+
- [ ] Breaking change
54+
- [x] Requires cluster rollout (new controller image; RBAC for
55+
`spotschedules.5spot.finos.org` get/list/watch lands in Phase 4)
56+
- [ ] Config change only
57+
- [ ] Documentation only
58+
59+
## [2026-06-13 18:45] - Spot-schedule resolver + composition + status surface (Phase 2)
60+
61+
**Author:** Erick Bourgeois
62+
63+
### Changed
64+
- `src/reconcilers/spot_schedule.rs`: NEW resolver module. `SpotScheduleVerdict`
65+
enum (`Active`/`Inactive`/`Unresolved`); pure `verdict_from_status` (duck-typed
66+
`status.active` + `Ready` extraction); async `resolve_spot_schedule`
67+
(`discovery::pinned_kind` + `get_opt`). Transient API errors propagate (retry);
68+
CRD-not-installed / object-absent / status-missing / not-Ready are `Unresolved`
69+
verdicts, never errors or panics. **Ready semantics finalized:** absent `Ready`
70+
`active` authoritative; present non-`True` `Ready` ⇒ unresolved (resolves an
71+
internal inconsistency in ADR 0006 §4 / contract table — both updated).
72+
- `src/reconcilers/helpers.rs`: NEW `compose_should_be_active` — AND composition
73+
of the inline schedule and the provider verdict, with hold-last-state on
74+
Unresolved (fail-inactive when never resolved). `killSwitch` precedence is
75+
upstream.
76+
- `src/crd.rs`: replaced Phase-1 `effective_schedule()` with `is_enabled()` (a
77+
spotSchedule-only machine is always enabled; the provider governs activity).
78+
NEW `SpotScheduleStatus` + `ScheduledMachineStatus.spot_schedule` field (the
79+
durable hold-last-state surface: `resolved`/`active`/`reason`/`message`/
80+
`providerGeneration`/`lastTransitionTime`).
81+
- `src/reconcilers/scheduled_machine.rs`: `reconcile_inner` now evaluates the
82+
optional inline schedule, resolves the provider (pull-on-reconcile), composes
83+
`should_be_active`, records metrics, and patches `status.spotSchedule`
84+
(`persist_spot_schedule_status`, a `/status` merge patch that coexists with the
85+
phase/condition writes). Handler `enabled` guards use `is_enabled()`.
86+
- `src/metrics.rs`: NEW `fivespot_spot_schedule_resolutions_total{namespace,kind,result}`,
87+
`_resolution_errors_total{namespace,kind,reason}`,
88+
`_transitions_total{namespace,kind}` (+ record fns; bounded cardinality, never
89+
keyed by name).
90+
- `deploy/crds/scheduledmachine.yaml` regenerated (status.spotSchedule);
91+
`docs/src/reference/api.md` regenerated.
92+
- Tests: `spot_schedule_tests.rs` (verdict truth table + 3 mock-client resolve
93+
paths), `helpers_tests.rs` (12-case composition truth table),
94+
`scheduled_machine_tests.rs` (persist `/status` patch), `metrics_tests.rs`,
95+
`crd_tests.rs` (status round-trip + is_enabled). 645 lib tests green.
96+
- Docs: ADR 0006 + contract page `Ready` clarification; contract page status →
97+
Phase 2; `docs/src/operations/monitoring.md` spot-schedule metrics table.
98+
99+
### Why
100+
Phase 2 of the spot-schedule provider roadmap: make a `spec.spotSchedule`
101+
reference actually drive the machine's active/inactive decision (pull-on-reconcile),
102+
with the ADR-specified hold-last-state fail-safe and an observable status surface.
103+
The event-driven watch (Phase 3) and the reference-provider controller (Phase 5)
104+
build on this.
105+
106+
### Impact
107+
- [ ] Breaking change
108+
- [x] Requires cluster rollout (apply regenerated CRDs + new controller image)
109+
- [ ] Config change only
110+
- [ ] Documentation only
111+
12112
## [2026-06-13 16:30] - Spot-schedule provider API: CRD scaffolding + ScheduledMachine v1beta1 (Phase 1)
13113

14114
**Author:** Erick Bourgeois

deploy/crds/scheduledmachine.yaml

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,47 @@ spec:
673673
(Pending, ShuttingDown, Inactive, Disabled, Terminated, Error) is
674674
reported as `False`.
675675
type: boolean
676+
spotSchedule:
677+
description: |-
678+
Spot-schedule provider resolution state (ADR 0006), present only when
679+
`spec.spotSchedule` is set. Carries the last resolved/held provider
680+
`active` value (the input to hold-last-state composition), the
681+
resolution reason/message, and the provider's observed generation.
682+
nullable: true
683+
properties:
684+
active:
685+
description: |-
686+
Last known provider `status.active`. Held across an unresolved reconcile
687+
(hold-last-state); `None` until the provider first resolves.
688+
nullable: true
689+
type: boolean
690+
lastTransitionTime:
691+
description: When `active` last transitioned (RFC3339).
692+
nullable: true
693+
type: string
694+
message:
695+
description: Human-readable resolution detail.
696+
nullable: true
697+
type: string
698+
providerGeneration:
699+
description: The provider's `status.observedGeneration` at the last resolution.
700+
format: int64
701+
nullable: true
702+
type: integer
703+
reason:
704+
description: Machine-readable resolution reason (a `REASON_SPOT_SCHEDULE_*` value).
705+
nullable: true
706+
type: string
707+
resolved:
708+
description: |-
709+
Whether the referenced provider resolved into an authoritative verdict
710+
this reconcile. `false` mirrors a `SpotScheduleResolved=False`
711+
condition — the provider CRD is absent, the object is missing, it
712+
exposes no `status.active`, or it is not `Ready`.
713+
type: boolean
714+
required:
715+
- resolved
716+
type: object
676717
type: object
677718
required:
678719
- spec
@@ -1204,6 +1245,47 @@ spec:
12041245
(Pending, ShuttingDown, Inactive, Disabled, Terminated, Error) is
12051246
reported as `False`.
12061247
type: boolean
1248+
spotSchedule:
1249+
description: |-
1250+
Spot-schedule provider resolution state (ADR 0006), present only when
1251+
`spec.spotSchedule` is set. Carries the last resolved/held provider
1252+
`active` value (the input to hold-last-state composition), the
1253+
resolution reason/message, and the provider's observed generation.
1254+
nullable: true
1255+
properties:
1256+
active:
1257+
description: |-
1258+
Last known provider `status.active`. Held across an unresolved reconcile
1259+
(hold-last-state); `None` until the provider first resolves.
1260+
nullable: true
1261+
type: boolean
1262+
lastTransitionTime:
1263+
description: When `active` last transitioned (RFC3339).
1264+
nullable: true
1265+
type: string
1266+
message:
1267+
description: Human-readable resolution detail.
1268+
nullable: true
1269+
type: string
1270+
providerGeneration:
1271+
description: The provider's `status.observedGeneration` at the last resolution.
1272+
format: int64
1273+
nullable: true
1274+
type: integer
1275+
reason:
1276+
description: Machine-readable resolution reason (a `REASON_SPOT_SCHEDULE_*` value).
1277+
nullable: true
1278+
type: string
1279+
resolved:
1280+
description: |-
1281+
Whether the referenced provider resolved into an authoritative verdict
1282+
this reconcile. `false` mirrors a `SpotScheduleResolved=False`
1283+
condition — the provider CRD is absent, the object is missing, it
1284+
exposes no `status.active`, or it is not `Ready`.
1285+
type: boolean
1286+
required:
1287+
- resolved
1288+
type: object
12071289
type: object
12081290
required:
12091291
- spec

docs/adr/0006-pluggable-spot-schedule-provider-contract.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,12 @@ killSwitch > (schedule AND spotSchedule) > schedule-only / spotSchedule-only
109109
### 4. Unresolved references never flap machines
110110

111111
A reference is **Unresolved** when the provider CRD is not installed, the named
112-
object is absent, it has no `status.active`, or its `Ready` condition is
113-
`False`/missing. On Unresolved, 5-Spot:
112+
object is absent, it has no `status.active`, or it carries a `Ready` condition
113+
whose status is **not `True`**. `Ready` is *recommended, not required*: a
114+
provider that **omits** `Ready` has its `status.active` taken as authoritative
115+
(resolved) — only a *present, non-`True`* `Ready` marks the reference
116+
unresolved, which is how a provider explicitly says "do not trust my `active`
117+
right now." On Unresolved, 5-Spot:
114118

115119
- sets a `SpotScheduleResolved=False` condition on the `ScheduledMachine` with
116120
a precise reason (`ProviderCRDNotInstalled`, `ProviderNotFound`,

docs/src/operations/monitoring.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,18 @@ All metrics use the `fivespot_` prefix. The full list lives in
6969
| `fivespot_errors_total` | Counter | `error_type` | Errors by type |
7070
| `fivespot_finalizer_cleanup_timeouts_total` | Counter | — | Finalizer cleanup timeouts (force-removed; possible orphans) |
7171

72+
#### Spot-schedule providers
73+
74+
Emitted only for `ScheduledMachine`s with a `spec.spotSchedule` reference
75+
(ADR 0006). Labels are bounded by `namespace` × provider `kind` — never the
76+
(unbounded) provider or machine name.
77+
78+
| Metric | Type | Labels | Description |
79+
| ------ | ---- | ------ | ----------- |
80+
| `fivespot_spot_schedule_resolutions_total` | Counter | `namespace`, `kind`, `result` | Provider resolutions; `result={active\|inactive\|unresolved}` |
81+
| `fivespot_spot_schedule_resolution_errors_total` | Counter | `namespace`, `kind`, `reason` | Unresolved resolutions by `reason` (`ProviderCRDNotInstalled`, `ProviderNotFound`, `StatusActiveMissing`, `ProviderNotReady`) — the hold-last-state signal to alert on |
82+
| `fivespot_spot_schedule_transitions_total` | Counter | `namespace`, `kind` | Provider `active`⇄`inactive` transitions; a high rate is the flapping signal |
83+
7284
#### Node drain & eviction
7385

7486
| Metric | Type | Labels | Description |

docs/src/reference/spot-schedule-contract.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@ SPDX-License-Identifier: Apache-2.0
44
-->
55
# Spot Schedule Provider Contract
66

7-
> **Status:** Phase 1 — the API types exist (`ScheduledMachine.spec.spotSchedule`
8-
> on `v1beta1`, the `CapitalMarketsSchedule` reference provider CRD), but the
9-
> controller-side **resolver and watch** land in later roadmap phases. The
10-
> contract is **Accepted** in [ADR 0006](https://github.com/finos/5-spot). This
11-
> page is the authoritative specification a provider author implements against.
7+
> **Status:** Phase 3 — the controller resolves a `spec.spotSchedule` reference,
8+
> folds the provider verdict into the machine's active/inactive decision
9+
> (`status.spotSchedule`), **and now watches referenced providers event-driven**:
10+
> a provider's `status.active` flip wakes the referencing machines at watch
11+
> latency (a dynamic per-GVK `watcher` stream, not polling). The
12+
> reference-provider **controller** that computes
13+
> `CapitalMarketsSchedule.status.active` lands in Phase 5. The contract is
14+
> **Accepted** in [ADR 0006](https://github.com/finos/5-spot). This page is the
15+
> authoritative specification a provider author implements against.
1216
1317
A **spot-schedule provider** is any Kubernetes custom resource in the
1418
`spotschedules.5spot.finos.org` API group that tells 5-Spot whether a
@@ -57,7 +61,7 @@ spec:
5761
| `status` field | Required | Type | Meaning |
5862
|---|:--:|---|---|
5963
| `active` | **yes** | bool | **The decision.** `true` ⇒ the referencing machine should be up; `false` ⇒ it should be down. |
60-
| `conditions[type=Ready]` | recommended | condition | Provider health. `Ready=False` (or absent) ⇒ 5-Spot treats the reference as **unresolved**, *not* inactive (see [Unresolved behavior](#unresolved-behavior)). |
64+
| `conditions[type=Ready]` | recommended | condition | Provider health. A *present* `Ready` whose status is **not `True`** ⇒ 5-Spot treats the reference as **unresolved**, *not* inactive (see [Unresolved behavior](#unresolved-behavior)). An **absent** `Ready` ⇒ `status.active` is taken as authoritative (`Ready` is recommended, not required). |
6165
| `observedGeneration` | recommended | int64 | The `metadata.generation` the status reflects; lets 5-Spot detect stale status. |
6266
| `lastTransitionTime` | recommended | RFC 3339 string | When `active` last flipped; used for observability and flap detection. |
6367

src/constants.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,19 @@ pub const MAX_RECONCILE_RETRIES: u32 = 10;
298298
/// steady-state ceiling.
299299
pub const CHILD_NODE_EVENT_CHANNEL_CAP: usize = 1024;
300300

301+
/// Buffer size for the mpsc channel that carries
302+
/// `ObjectRef<ScheduledMachine>` from the dynamic spot-schedule provider
303+
/// watchers into the `Controller::reconcile_on` stream (ADR 0006, Phase 3).
304+
/// One channel is shared across every per-GVK provider watcher.
305+
pub const SPOT_SCHEDULE_EVENT_CHANNEL_CAP: usize = 1024;
306+
307+
/// Back-off between retries when a spot-schedule provider watcher cannot yet
308+
/// resolve its `ApiResource` via discovery (the provider CRD is referenced but
309+
/// not installed) or its watch stream terminates (e.g. the CRD was deleted).
310+
/// kube-runtime reconnects transient watch errors internally; this back-off
311+
/// only governs the discovery re-resolution loop.
312+
pub const SPOT_SCHEDULE_DISCOVERY_RETRY_SECS: u64 = 30;
313+
301314
/// Maximum number of cached child-cluster `kube::Client` instances kept in
302315
/// memory by [`crate::reconcilers::child_client::ChildClientCache`].
303316
///

0 commit comments

Comments
 (0)