Skip to content

refactor(sablier): stage 1 of the InstanceInfo split - typed InstanceConfig with one parser and one enabled-semantics#1023

Merged
acouvreur merged 2 commits into
mainfrom
refactor/instance-config
Jul 11, 2026
Merged

refactor(sablier): stage 1 of the InstanceInfo split - typed InstanceConfig with one parser and one enabled-semantics#1023
acouvreur merged 2 commits into
mainfrom
refactor/instance-config

Conversation

@acouvreur

Copy link
Copy Markdown
Member

Finding

InstanceInfo (instance.go) is four things at once:

  1. Provider-reported state - Status, CurrentReplicas, DesiredReplicas (plus ReadyAt, whose own comment admits it "is set internally by Sablier and is never populated by a provider").
  2. Parsed-label configuration bag - Enabled is a string compared against "true" in six scattered places (autostop, autowarm, expiration, request handling, running-hours watcher, and the parser itself); RunningHours/RunningDays are raw strings; ReadyAfter, ReadyOnStart, AntiAffinity, ScaleConfig ride along.
  3. The API wire format - marshalled directly into responses and the SSE stream; every field is OpenAPI schema surface that reverse-proxy plugins can parse.
  4. The persisted session record - the whole struct is JSON-serialized into the store, freezing stale label config into sessions.

Every feature adds a field here and touches all four concerns at once; nobody can tell which fields a provider must set, which Sablier owns, and which are configuration. Label parsing also warned through the global slog, bypassing the configured handler.

Approach: staged, wire-compatible

The full split has two compatibility cliffs - the wire format (plugins) and the persisted records (upgrades with live state) - so it lands in stages. This PR is stage 1, scoped to the configuration concern, and changes zero existing bytes on the wire.

What this stage does

  • One parser. All sablier.* label interpretation now lives in InstanceConfigFromLabels, producing a typed InstanceConfig (Enabled bool, real time.Duration, validated window strings, Scale *ScaleConfig). It takes a logger (restoring per-instance context on warnings) instead of the global slog.
  • One semantics. The six Enabled == "true" string comparisons collapse into IsEnabled() accessors on InstanceInfo and InstanceConfiguration. The InstanceInfo accessor prefers the parsed config and falls back to the flat field for session records persisted by older versions.
  • Compatibility preserved. PopulateEnabledAndGroup (the function all five providers call) becomes a thin adapter: it stores the typed config under a new additive config key and mirrors it into the flat legacy fields byte-identically - enabled stays the raw label string the API has always exposed.

Evidence it is behavior-preserving

  • Every pre-existing PopulateEnabledAndGroup test (ready-after, groups, running-hours, anti-affinity, scale labels, blkio labels) passes unchanged - they assert the flat fields.
  • A new TestPopulateEnabledAndGroup_WireCompat pins the contract: legacy keys keep their exact names and raw values, config is strictly additive.
  • A new fallback test unmarshals a pre-config session record and asserts IsEnabled() still answers correctly.
  • Full parser table test for InstanceConfigFromLabels (enable exact-match semantics, group gating, invalid-value warn-and-ignore, scale-presence condition).

openapi.json regenerated (gains the additive sablier.InstanceConfig schema); make check-generate, full test suite and golangci-lint pass.

Next stages (separate PRs)

  1. API DTO in internal/api so the wire format stops being the domain struct (pairs with the OpenAPI drift finding).
  2. Versioned session record so the store stops persisting label configuration, and the flat config fields can then be dropped from InstanceInfo.
  3. Provider contract cleanup (ReadyAt and friends move off the provider-facing type).

Docs

No documentation impact beyond the regenerated OpenAPI spec: no labels, config options, or behavior changed.

… god struct

InstanceInfo is four things at once: provider-reported state, a parsed-label
configuration bag, the API wire format, and the persisted session record.
This is the first stage of pulling those apart, scoped to the configuration
concern and deliberately wire-compatible.

All sablier.* label parsing now lives in one function,
InstanceConfigFromLabels, producing a typed InstanceConfig (Enabled is a
bool, durations are durations, window values are validated). It takes a
logger instead of using the global slog, restoring the instance context on
warnings. PopulateEnabledAndGroup becomes a thin adapter that providers keep
calling: it stores the typed config under the new additive `config` field
and mirrors it into the flat legacy fields, which stay byte-identical for
API consumers and persisted session records (pinned by a wire-compat test;
every pre-existing PopulateEnabledAndGroup test passes unchanged).

The scattered `Enabled == "true"` string comparisons (six call sites across
autostop, autowarm, expiration, request handling and the running-hours
watcher) collapse into IsEnabled() accessors on InstanceInfo and
InstanceConfiguration. The InstanceInfo accessor prefers the parsed config
and falls back to the flat field for records persisted by older versions.

Next stages (separate PRs): an API DTO so the wire format stops being the
domain struct, and a versioned session record so the store stops persisting
label config.
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jul 11, 2026
@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown

Test Results

✅ All tests passed! | 779 tests in 89.585s

View HTML Test Report

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
┌───────────────────────────────────────────────────────────────────────────────┐
│ Diff between sablier and sablier                                              │
├─────────┬─────────────────────────────────────┬──────────┬──────────┬─────────┤
│ PERCENT │ NAME                                │ OLD SIZE │ NEW SIZE │ DIFF    │
├─────────┼─────────────────────────────────────┼──────────┼──────────┼─────────┤
│ +0.80%  │ go.yaml.in/yaml/v3                  │ 312 kB   │ 314 kB   │ +2.5 kB │
│ +0.26%  │ github.com/sablierapp/sablier       │ 654 kB   │ 656 kB   │ +1.7 kB │
│ +0.27%  │ vendor/golang.org/x/net/http2/hpack │ 35 kB    │ 35 kB    │ +95 B   │
│ +1.00%  │ go.shape                            │ 4.8 kB   │ 4.8 kB   │ +48 B   │
│ +0.04%  │ k8s.io/klog/v2                      │ 124 kB   │ 124 kB   │ +45 B   │
│ +0.01%  │ encoding                            │ 418 kB   │ 418 kB   │ +36 B   │
│ +0.01%  │ go.yaml.in/yaml/v2                  │ 275 kB   │ 275 kB   │ +26 B   │
│ +0.00%  │ runtime                             │ 3.3 MB   │ 3.3 MB   │ +25 B   │
│ +0.00%  │ github.com/google/gnostic-models    │ 1.6 MB   │ 1.6 MB   │ +21 B   │
│ +0.16%  │ vendor/golang.org/x/sys/cpu         │ 6.4 kB   │ 6.4 kB   │ +10 B   │
│ +0.00%  │ crypto                              │ 1.9 MB   │ 1.9 MB   │ +10 B   │
│ +0.00%  │ k8s.io/api                          │ 17 MB    │ 17 MB    │ +9 B    │
│ +0.01%  │ github.com/spf13/viper              │ 73 kB    │ 73 kB    │ +7 B    │
│ +0.00%  │ google.golang.org/grpc              │ 1.2 MB   │ 1.2 MB   │ +1 B    │
│ +0.00%  │ k8s.io/apimachinery                 │ 1.8 MB   │ 1.8 MB   │ +1 B    │
│ +0.00%  │ github.com/emicklei/go-restful/v3   │ 134 kB   │ 134 kB   │ +1 B    │
│ -0.00%  │ net                                 │ 1.7 MB   │ 1.7 MB   │ -1 B    │
│ -0.00%  │ go.mongodb.org/mongo-driver/v2      │ 674 kB   │ 674 kB   │ -2 B    │
│ -0.00%  │ github.com/quic-go/quic-go          │ 1.3 MB   │ 1.3 MB   │ -3 B    │
│ -0.02%  │ golang.org/x/sys                    │ 46 kB    │ 46 kB    │ -10 B   │
│ -0.00%  │ k8s.io/client-go                    │ 14 MB    │ 14 MB    │ -22 B   │
│ -0.11%  │ vendor/golang.org/x/net/idna        │ 22 kB    │ 22 kB    │ -25 B   │
│ -0.02%  │ sigs.k8s.io/json                    │ 173 kB   │ 173 kB   │ -36 B   │
│ -0.14%  │ k8s.io/utils                        │ 32 kB    │ 32 kB    │ -45 B   │
│ -0.02%  │ github.com/gin-gonic/gin            │ 337 kB   │ 337 kB   │ -56 B   │
│ -0.01%  │ golang.org/x/net                    │ 902 kB   │ 902 kB   │ -70 B   │
│ -0.15%  │ go.opentelemetry.io/auto/sdk        │ 89 kB    │ 89 kB    │ -131 B  │
│ -0.13%  │ text/template                       │ 293 kB   │ 292 kB   │ -387 B  │
│ -0.82%  │ gopkg.in/yaml.v3                    │ 306 kB   │ 303 kB   │ -2.5 kB │
│ -0.27%  │ go.opentelemetry.io/otel            │ 1.1 MB   │ 1.1 MB   │ -2.8 kB │
├─────────┼─────────────────────────────────────┼──────────┼──────────┼─────────┤
│ -0.00%  │ .rodata                             │ 2.6 MB   │ 2.6 MB   │ -126 B  │
├─────────┼─────────────────────────────────────┼──────────┼──────────┼─────────┤
│ +0.01%  │ sablier                             │ 62 MB    │ 62 MB    │ +4.1 kB │
│         │ sablier                             │          │          │         │
└─────────┴─────────────────────────────────────┴──────────┴──────────┴─────────┘

The provider inspect integration tests compare the full InstanceInfo with
DeepEqual; they now fail because inspect results carry the new additive
Config field. Since PopulateEnabledAndGroup mirrors the parsed config 1:1
into the flat fields each test case already declares, the expectation is
derived from those fields at the existing per-case fixup block instead of
hand-writing ~70 Config literals. This also keeps working for the
Kubernetes cases that configure sablier via annotations.

Verified against the real runtimes locally: docker (dind), swarm (dind),
kubernetes (k3s, including the annotation cases) and podman (pind) inspect
suites all pass.
@acouvreur

Copy link
Copy Markdown
Member Author

CI failures fixed: the provider inspect suites DeepEqual the full InstanceInfo and needed the new additive Config field in their expectations. Since PopulateEnabledAndGroup mirrors the parsed config 1:1 into the flat fields each case already declares, the expectation is now derived from those fields at the per-case fixup block (works for the K8s annotation cases too). Verified locally against dind (docker + swarm), k3s and pind - all four inspect suites pass.

@github-actions github-actions Bot added the provider Issue related to a provider label Jul 11, 2026
@sonarqubecloud

Copy link
Copy Markdown

@acouvreur acouvreur merged commit 65b82c2 into main Jul 11, 2026
9 checks passed
@acouvreur acouvreur deleted the refactor/instance-config branch July 11, 2026 14:57
acouvreur added a commit that referenced this pull request Jul 11, 2026
…truct (#1024)

The blocking endpoint answered with an ad-hoc map{"session": sessionState}
whose bytes were shaped by a custom SessionState.MarshalJSON, while the
OpenAPI schema was generated from the struct tags. The two had drifted
apart from day one: the published schema said `instances` was a map of
name to entry and had no `status` property, while the wire carried an
ARRAY of {instance, error} entries plus `status`. Anyone building a
reverse-proxy plugin against the published contract parsed the wrong
shape. The SessionResponse/ThemesResponse types existed only to feed the
annotations; nothing kept them in sync with what handlers actually wrote.

The handlers now build and return the annotated DTOs (NewSessionResponse,
ThemesResponse), so the generated schema is derived from the same types
that produce the bytes and cannot drift again. The wire shape is unchanged
except that instance entries are now sorted by name instead of
map-iteration random (ordering was never part of the contract).

SessionState loses its MarshalJSON: the domain type no longer carries wire
concerns, which is stage 2 of the InstanceInfo/session split (stage 1:
#1023). Its marshalling tests move to the API package against the DTO,
joined by a wire-shape pin so the array+status contract is enforced by
test rather than by accident.
acouvreur added a commit that referenced this pull request Jul 11, 2026
Both stores serialized the raw domain InstanceInfo as the persisted session
entry, making the storage schema "whatever the domain struct happens to
marshal as": every field added to InstanceInfo silently became persisted
state with no version to gate evolution and no way to recognize records
written by an older release.

Sessions are now wrapped in a SessionRecord envelope ({"v":1,"instance":
{...}}) owned next to the Store interface. Its unmarshaler transparently
upgrades pre-versioning payloads (bare InstanceInfo documents), so live
sessions in valkey and state.json snapshots survive the upgrade instead of
being dropped. Foreign keys sharing the valkey keyspace keep the previous
tolerance: they decode to a record with an empty instance name and are
skipped by the existing name check.

The Store interface is unchanged (Get/Put still speak InstanceInfo), so the
core and providers are untouched; this is stage 3 of the InstanceInfo split
(stage 1: #1023, stage 2: #1024) and the prerequisite for slimming what
sessions actually persist, which will be a schema-version bump instead of a
silent break.

Migration coverage: record round-trip and legacy-upgrade unit tests, an
inmemory legacy-snapshot load test, and valkey testcontainers tests reading
a legacy payload through Get and Range and asserting new entries carry the
version. The sabliercmd storage fixtures already use the legacy shape, so
the state-file load tests now exercise the upgrade end to end.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation provider Issue related to a provider

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant