provision, registry: rate_limit on the instance record and route, converged like the gate (#358)
Record.RateLimit (a *ratelimit.Settings, nil = defaults) validates in ValidateShape, mints through RouteFor and converges through the owned arm via the new Registry.SetRateLimit and its remote-dispatch twin. The routes column is rate_limit TEXT NOT NULL DEFAULT '', preserved by Upsert, and folded into Fingerprint and SnapshotFingerprint only when non-empty, so a registry with no custom limits digests byte-identically to before. The two census tests (TestPlan9B8RecordWriteCensus, TestPlan14RootReadCensus) now flag the touched functions; they are not re-pinned here and need the census owner's approval.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+453
−7
internal/provision/provision.go+25 −0internal/provision/ratelimit_record_test.go+93 −0internal/provision/reconcile.go+19 −0internal/provision/reconcile_ratelimit_test.go+102 −0internal/registry/ratelimit_column_test.go+122 −0internal/registry/ratelimit_fingerprint_test.go+37 −0internal/registry/registry.go+52 −7internal/registry/remote_dispatch.go+3 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index a5657b0cb..61014fad1 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -25,6 +25,7 @@ import ( |
| "github.com/carlosframework/platform/internal/controlname" |
| "github.com/carlosframework/platform/internal/controlsock" |
| + "github.com/carlosframework/platform/internal/ratelimit" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "golang.org/x/crypto/bcrypt" |
| @@ -172,6 +173,15 @@ type Record struct { |
| // (reconcile.go) and read by the edge on every request. Spec docs/superpowers/specs/2026-09-07-password-gate-design.md. |
| Gate string `json:"gate,omitempty"` |
| + // RateLimit is the owner's rate-limit settings for the host (spec |
| + // docs/superpowers/specs/2026-10-24-edge-rate-limit-design.md). Nil means |
| + // the defaults, and so does a pointer to the zero Settings: both give an |
| + // empty column. A pointer so a record that never set it marshals without |
| + // the key and keeps its bytes. Converged into routes.rate_limit by |
| + // Reconcile's owned-converge arm, beside Gate, and read by the edge at |
| + // reload. |
| + RateLimit *ratelimit.Settings `json:"rate_limit,omitempty"` |
| + |
| // --- replica fields (carlos/platform discussion #162, edge checkout) --- |
| // Role is what this instance IS within its app: "" (the default every |
| @@ -436,6 +446,16 @@ func (r Record) HealthOrDefault() *Health { |
| return nil |
| } |
| +// RateLimitColumn is the record's rate-limit settings as the routes column |
| +// stores them: the canonical JSON, or "" for the defaults. A nil RateLimit and |
| +// a pointer to the zero Settings are the same fact, so both give "". |
| +func (rec Record) RateLimitColumn() string { |
| + if rec.RateLimit == nil { |
| + return "" |
| + } |
| + return rec.RateLimit.Canonical() |
| +} |
| + |
| // HibernateOrDefault resolves the tri-state. An explicit value wins. Absent |
| // derives: a socket-backed instance hibernates (the activator model, and |
| // what reconcile minted unconditionally before this field existed, so every |
| @@ -593,6 +613,11 @@ func ValidateShape(rec Record) error { |
| return errors.New("gate: want a bcrypt hash — the console hashes the password; a record never carries it in clear") |
| } |
| } |
| + if rec.RateLimit != nil { |
| + if err := rec.RateLimit.Validate(); err != nil { |
| + return err // Validate's messages already lead with the rate_limit field path |
| + } |
| + } |
| if rec.Addr != "" { |
| if err := validAddr(rec.Addr); err != nil { |
| return err |
diff --git a/internal/provision/ratelimit_record_test.go b/internal/provision/ratelimit_record_test.go| new file mode 100644 |
| index 000000000..2f48d92d9 |
| --- /dev/null |
| +++ b/internal/provision/ratelimit_record_test.go |
| @@ -0,0 +1,93 @@ |
| +package provision |
| + |
| +import ( |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/ratelimit" |
| +) |
| + |
| +func TestValidateShapeRateLimit(t *testing.T) { |
| + base := Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", |
| + Account: "bab", Channel: "stable"} |
| + |
| + // A nil pointer, the zero value and a sane value all pass. |
| + for name, rl := range map[string]*ratelimit.Settings{ |
| + "nil": nil, |
| + "zero": {}, |
| + "valid": {BusyAt: 10, Rule: ratelimit.Rule{Rate: 50}, Paths: []ratelimit.PathRule{{Prefix: "/api/", Rule: ratelimit.Rule{Rate: 5}}}}, |
| + } { |
| + rec := base |
| + rec.RateLimit = rl |
| + if err := ValidateShape(rec); err != nil { |
| + t.Errorf("%s: ValidateShape = %v, want nil", name, err) |
| + } |
| + } |
| + |
| + // Nine paths is one past MaxPaths. |
| + var nine []ratelimit.PathRule |
| + for i := 0; i < ratelimit.MaxPaths+1; i++ { |
| + nine = append(nine, ratelimit.PathRule{Prefix: "/p" + string(rune('a'+i))}) |
| + } |
| + for name, rl := range map[string]*ratelimit.Settings{ |
| + "nine paths": {Paths: nine}, |
| + "negative rate": {Rule: ratelimit.Rule{Rate: -1}}, |
| + "relative path": {Paths: []ratelimit.PathRule{{Prefix: "api"}}}, |
| + "value too high": {BusyAt: ratelimit.MaxValue + 1}, |
| + } { |
| + rec := base |
| + rec.RateLimit = rl |
| + err := ValidateShape(rec) |
| + if err == nil { |
| + t.Errorf("%s: ValidateShape = nil, want an error", name) |
| + continue |
| + } |
| + if !strings.Contains(err.Error(), "rate_limit") { |
| + t.Errorf("%s: error %q does not name rate_limit", name, err) |
| + } |
| + } |
| +} |
| + |
| +// A record written before the field existed has no rate_limit key. It must |
| +// decode to nil, and its column must be the empty string: the defaults. |
| +func TestRecordWithoutRateLimitDecodes(t *testing.T) { |
| + body := `{"schema":1,"host":"acme.go.tito.io","slug":"acme","app":"tito","account":"bab","channel":"stable"}` |
| + var rec Record |
| + if err := json.Unmarshal([]byte(body), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.RateLimit != nil { |
| + t.Errorf("RateLimit = %+v, want nil", rec.RateLimit) |
| + } |
| + if got := rec.RateLimitColumn(); got != "" { |
| + t.Errorf("RateLimitColumn = %q, want empty", got) |
| + } |
| + // And a nil one marshals without the key, so an untouched record's bytes do not change. |
| + out, err := json.Marshal(rec) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Contains(string(out), "rate_limit") { |
| + t.Errorf("marshalled record carries rate_limit: %s", out) |
| + } |
| +} |
| + |
| +func TestRecordRateLimitColumn(t *testing.T) { |
| + zero := Record{RateLimit: &ratelimit.Settings{}} |
| + if got := zero.RateLimitColumn(); got != "" { |
| + t.Errorf("zero Settings column = %q, want empty", got) |
| + } |
| + s := ratelimit.Settings{Rule: ratelimit.Rule{Rate: 50}} |
| + rec := Record{RateLimit: &s} |
| + if got, want := rec.RateLimitColumn(), s.Canonical(); got != want || got == "" { |
| + t.Errorf("column = %q, want %q", got, want) |
| + } |
| + var rec2 Record |
| + if err := json.Unmarshal([]byte(`{"rate_limit":{"rate":50}}`), &rec2); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec2.RateLimit == nil || rec2.RateLimit.Rate != 50 { |
| + t.Errorf("decoded RateLimit = %+v", rec2.RateLimit) |
| + } |
| +} |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 31945adc1..a05515a7d 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -443,6 +443,7 @@ func RouteFor(rec Record, dataDir, seed string, nested bool, policy *UpdatePolic |
| Replicas: replicaSet(rec.Replicas), |
| Replica: rec.Role == "replica", |
| Gate: rec.Gate, |
| + RateLimit: rec.RateLimitColumn(), |
| Provisioned: true, Seed: seed, Status: "ready", |
| // The record's own settings over the app's policy (settings spec |
| // §3.2); "" for default settings, so an untouched fleet mints the |
| @@ -1372,6 +1373,15 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| log.Printf("provision: converged %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate)) |
| } |
| } |
| + if col := rec.RateLimitColumn(); existing.RateLimit != col { |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would converge %s rate limit %s -> %s", rec.Host, rateLimitWord(existing.RateLimit), rateLimitWord(col)) |
| + } else if err := reg.SetRateLimit(rec.Host, col); err != nil { |
| + log.Printf("provision: converge rate limit on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s rate limit %s -> %s", rec.Host, rateLimitWord(existing.RateLimit), rateLimitWord(col)) |
| + } |
| + } |
| // The seed source goes first, and the seed command follows only |
| // once it has landed (seeded-instances preflight M10). A row |
| // whose record has a seed gets its seed column before its seed |
| @@ -1800,6 +1810,15 @@ func gateWord(hash string) string { |
| return "set" |
| } |
| +// rateLimitWord renders a rate_limit column for a log line: "default" or |
| +// "custom", never the settings themselves. |
| +func rateLimitWord(col string) string { |
| + if col == "" { |
| + return "default" |
| + } |
| + return "custom" |
| +} |
| + |
| // knownAccounts lists accounts/ and materializes the sqid set this pass |
| // trusts for ParseRecordKey's "known account" guard — the same three-way |
| // sqid agreement domains.Reconcile enforces (reconcile.go:106): the key |
diff --git a/internal/provision/reconcile_ratelimit_test.go b/internal/provision/reconcile_ratelimit_test.go| new file mode 100644 |
| index 000000000..2a03ce6a9 |
| --- /dev/null |
| +++ b/internal/provision/reconcile_ratelimit_test.go |
| @@ -0,0 +1,102 @@ |
| +package provision |
| + |
| +import ( |
| + "bytes" |
| + "log" |
| + "os" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/ratelimit" |
| +) |
| + |
| +// The owned-converge arm moves a row's rate_limit column to the record's |
| +// canonical value in both directions, through the real Reconcile pass, and a |
| +// freshly minted row carries it from RouteFor. |
| +func TestReconcileConvergesRateLimit(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + if before.RateLimit != "" { |
| + t.Fatalf("minted RateLimit = %q, want defaults", before.RateLimit) |
| + } |
| + |
| + var logged bytes.Buffer |
| + log.SetOutput(&logged) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + |
| + // Paths out of order: the column is the canonical (sorted) form. |
| + set := ratelimit.Settings{Rule: ratelimit.Rule{Rate: 50}, Paths: []ratelimit.PathRule{ |
| + {Prefix: "/b/", Rule: ratelimit.Rule{Rate: 2}}, |
| + {Prefix: "/a/", Rule: ratelimit.Rule{Rate: 1}}, |
| + }} |
| + rec.RateLimit = &set |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after.RateLimit != set.Canonical() { |
| + t.Errorf("RateLimit = %q, want %q", after.RateLimit, set.Canonical()) |
| + } |
| + if !strings.Contains(logged.String(), "converged acme.go.tito.io rate limit default -> custom") { |
| + t.Errorf("no default -> custom line logged; log was:\n%s", logged.String()) |
| + } |
| + if strings.Contains(logged.String(), `"rate"`) || strings.Contains(logged.String(), "/a/") { |
| + t.Errorf("the settings JSON reached the log:\n%s", logged.String()) |
| + } |
| + after.RateLimit = before.RateLimit |
| + if after != before { |
| + t.Errorf("converging rate_limit touched another column:\n before %+v\n after %+v", before, after) |
| + } |
| + |
| + // A second pass changes nothing and logs nothing. |
| + logged.Reset() |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Contains(logged.String(), "rate limit") { |
| + t.Errorf("an unchanged record converged again:\n%s", logged.String()) |
| + } |
| + |
| + // A record with the zero Settings, and one with no key at all, are both |
| + // the defaults, and clearing reaches the row. |
| + rec.RateLimit = &ratelimit.Settings{} |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + logged.Reset() |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").RateLimit; got != "" { |
| + t.Errorf("RateLimit = %q, want it cleared", got) |
| + } |
| + if !strings.Contains(logged.String(), "converged acme.go.tito.io rate limit custom -> default") { |
| + t.Errorf("no custom -> default line logged; log was:\n%s", logged.String()) |
| + } |
| +} |
| + |
| +func TestReconcileMintsRateLimit(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + set := ratelimit.Settings{BusyAt: 20} |
| + rec.RateLimit = &set |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").RateLimit; got != set.Canonical() { |
| + t.Errorf("minted RateLimit = %q, want %q", got, set.Canonical()) |
| + } |
| +} |
diff --git a/internal/registry/ratelimit_column_test.go b/internal/registry/ratelimit_column_test.go| new file mode 100644 |
| index 000000000..624a561f4 |
| --- /dev/null |
| +++ b/internal/registry/ratelimit_column_test.go |
| @@ -0,0 +1,122 @@ |
| +package registry |
| + |
| +import "testing" |
| + |
| +const rlCol = `{"busy_at":20,"rate":50}` |
| + |
| +func TestRateLimitColumnRoundTrip(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + const host = "rl.example.com" |
| + if err := reg.Upsert(Route{Host: host, Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetRateLimit(host, rlCol); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustRoute(t, reg, host).RateLimit; got != rlCol { |
| + t.Fatalf("RateLimit = %q, want %q", got, rlCol) |
| + } |
| + if err := reg.SetRateLimit("nope.example.com", rlCol); err == nil { |
| + t.Fatal("SetRateLimit on an unmatched host: want error, got nil") |
| + } |
| +} |
| + |
| +// A full-row writer that predates the column must not reset an owner's |
| +// limits to the defaults; SetRateLimit is the one door that clears them. |
| +func TestUpsertPreservesRateLimit(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + const host = "rl.example.com" |
| + if err := reg.Upsert(Route{Host: host, Kind: "instance", Status: "ready", RateLimit: rlCol}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustRoute(t, reg, host).RateLimit; got != rlCol { |
| + t.Fatalf("RateLimit after upsert = %q", got) |
| + } |
| + if err := reg.Upsert(Route{Host: host, Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustRoute(t, reg, host).RateLimit; got != rlCol { |
| + t.Fatalf("RateLimit after a column-less upsert = %q, want it preserved", got) |
| + } |
| + if err := reg.SetRateLimit(host, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustRoute(t, reg, host).RateLimit; got != "" { |
| + t.Fatalf("RateLimit after SetRateLimit(\"\") = %q, want cleared", got) |
| + } |
| +} |
| + |
| +func TestFingerprintMovesOnRateLimit(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + const host = "rl.example.com" |
| + if err := reg.Upsert(Route{Host: host, Kind: "static", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + fp := func() string { |
| + t.Helper() |
| + v, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return v |
| + } |
| + snap := func() string { |
| + t.Helper() |
| + v, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return v |
| + } |
| + f0, s0 := fp(), snap() |
| + if err := reg.SetRateLimit(host, rlCol); err != nil { |
| + t.Fatal(err) |
| + } |
| + f1, s1 := fp(), snap() |
| + if f1 == f0 { |
| + t.Fatal("rate_limit set is invisible to Fingerprint: the edge would keep the old limits") |
| + } |
| + if s1 == s0 { |
| + t.Fatal("rate_limit set is invisible to SnapshotFingerprint") |
| + } |
| + if err := reg.SetRateLimit(host, `{"busy_at":20,"rate":51}`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp() == f1 { |
| + t.Fatal("a changed rate_limit value is invisible to Fingerprint") |
| + } |
| + if err := reg.SetRateLimit(host, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Cleared is the digest it was before it was ever set. |
| + if got := fp(); got != f0 { |
| + t.Fatalf("Fingerprint after clearing = %q, want the original %q", got, f0) |
| + } |
| + if got := snap(); got != s0 { |
| + t.Fatalf("SnapshotFingerprint after clearing = %q, want the original %q", got, s0) |
| + } |
| +} |
| + |
| +func TestRemoteSetRateLimit(t *testing.T) { |
| + r, _ := remoteTestRegistry(t) |
| + const host = "remote.example.com" |
| + if err := r.Upsert(Route{Host: host, Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.SetRateLimit(host, rlCol); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := r.Get(host) |
| + if err != nil || got == nil || got.RateLimit != rlCol { |
| + t.Fatalf("route = %+v, %v", got, err) |
| + } |
| + if err := r.SetRateLimit(host, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := r.Get(host); err != nil || got == nil || got.RateLimit != "" { |
| + t.Fatalf("route after clearing = %+v, %v", got, err) |
| + } |
| + if err := r.SetRateLimit("nobody.example.com", rlCol); err == nil { |
| + t.Fatal("an unknown host was not an error") |
| + } |
| +} |
diff --git a/internal/registry/ratelimit_fingerprint_test.go b/internal/registry/ratelimit_fingerprint_test.go| new file mode 100644 |
| index 000000000..beb485a43 |
| --- /dev/null |
| +++ b/internal/registry/ratelimit_fingerprint_test.go |
| @@ -0,0 +1,37 @@ |
| +package registry |
| + |
| +import "testing" |
| + |
| +// A registry with no rate-limited rows digests exactly as it did before the |
| +// column existed, so rolling this out reloads no edge and forces no snapshot. |
| +// The digests are hard-coded from the encoding before the column was added, |
| +// for a fixed fixture; they must never be re-pinned to follow a change to the |
| +// rate_limit term. |
| +func TestFingerprintsUnchangedWithoutRateLimit(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + for _, rt := range []Route{ |
| + {Host: "a.example.com", Kind: "static", Status: "ready", CreatedAt: 1000}, |
| + {Host: "b.example.com", Kind: "instance", Status: "ready", Socket: "/run/b.sock", DBPath: "/data/b.db", |
| + Version: "v1", Channel: "stable", Region: "ie", Hibernate: true, CreatedAt: 1000}, |
| + {Host: "c.example.com", Kind: "service", Addr: "127.0.0.1:9", Status: "ready", Gate: "$2a$10$hash", CreatedAt: 1000}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + fp, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + snap, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + const wantFP, wantSnap = "3:d5ce261390a371e30d5096af5f99d0f8db77ac9ea6b8a7a8ff779585b9a20051", "3:b0eafec28c73c277183b73cd69ec5fe9d443781ebde4ce3c795ce51ec5ddc79d" |
| + if fp != wantFP { |
| + t.Errorf("Fingerprint = %s, want %s", fp, wantFP) |
| + } |
| + if snap != wantSnap { |
| + t.Errorf("SnapshotFingerprint = %s, want %s", snap, wantSnap) |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 4447cc982..9c4b0db2b 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -181,6 +181,17 @@ type Route struct { |
| // column can never silently open a gated host. |
| Gate string |
| + // RateLimit is the instance owner's rate-limit settings in canonical |
| + // JSON (internal/ratelimit.Settings.Canonical), converged from |
| + // provision.Record.RateLimit by SetRateLimit. "" is the defaults, the |
| + // value every row written before the column existed carries. The edge |
| + // parses it at reload, so it is IN Fingerprint(): a change must reach a |
| + // running edge. Validated at the console door, never here (SetCaps's |
| + // rule). Upsert preserves a non-empty value against an empty one, the |
| + // gate's idiom, so a full-row writer that predates the column cannot |
| + // quietly reset an owner's limits. |
| + RateLimit string |
| + |
| Canary bool |
| // Replication is the route's litestream sync class — the rung of the |
| // replication ladder its database is replicated on. The vocabulary is |
| @@ -609,6 +620,7 @@ CREATE TABLE IF NOT EXISTS routes ( |
| `ALTER TABLE routes ADD COLUMN build_state TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN seed_source TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN import_gate TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE routes ADD COLUMN rate_limit TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -897,10 +909,10 @@ dns_checked_at, cert_expires_at, account, environment, caps, tunnel, |
| provisioned, seed, unit, fleet_box, addrs, health, steering, gate, created_at, |
| args, idle_seconds, max_run_seconds, replicas, replica, claim_incarnation, claim_stamp, claim_owner, claim_row_id, hold_op, |
| good_version, good_digest, good_demand, serving_version, update_settings, build_state, |
| -seed_source, import_gate` |
| +seed_source, import_gate, rate_limit` |
| // routeColCount is the number of names in routeCols. |
| -const routeColCount = 55 |
| +const routeColCount = 56 |
| // placeholders is n comma-joined bind parameters. |
| func placeholders(n int) string { |
| @@ -918,7 +930,7 @@ func scanRoute(scan func(...any) error) (Route, error) { |
| &t.Addrs, &t.Health, &t.Steering, &t.Gate, &t.CreatedAt, |
| &t.Args, &t.IdleSeconds, &t.MaxRunSeconds, &t.Replicas, &rep, &t.ClaimIncarnation, &t.ClaimStamp, &t.ClaimOwner, &t.ClaimRowID, &t.HoldOp, |
| &t.GoodVersion, &t.GoodDigest, &t.GoodDemand, &t.ServingVersion, &t.UpdateSettings, &t.BuildState, |
| - &t.SeedSource, &t.ImportGate) |
| + &t.SeedSource, &t.ImportGate, &t.RateLimit) |
| t.Hibernate = hib != 0 |
| t.Canary = can != 0 |
| t.StatusNamed = sn != 0 |
| @@ -1201,6 +1213,7 @@ ON CONFLICT(host) DO UPDATE SET |
| addrs=excluded.addrs, health=excluded.health, steering=excluded.steering, |
| replicas=excluded.replicas, |
| gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END, |
| + rate_limit=CASE WHEN excluded.rate_limit = '' THEN routes.rate_limit ELSE excluded.rate_limit END, |
| args=CASE WHEN excluded.args = '' THEN routes.args ELSE excluded.args END, |
| idle_seconds=CASE WHEN excluded.idle_seconds = 0 THEN routes.idle_seconds ELSE excluded.idle_seconds END, |
| max_run_seconds=CASE WHEN excluded.max_run_seconds = 0 THEN routes.max_run_seconds ELSE excluded.max_run_seconds END, |
| @@ -1269,7 +1282,7 @@ func routeArgs(t Route) []any { |
| t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds, |
| t.Replicas, b2iDB(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp, |
| t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings, t.BuildState, |
| - t.SeedSource, t.ImportGate, |
| + t.SeedSource, t.ImportGate, t.RateLimit, |
| } |
| } |
| @@ -2107,6 +2120,31 @@ func (r *Registry) SetGate(host, hash string) error { |
| return nil |
| } |
| +// SetRateLimit stamps or clears a route's rate-limit settings — the narrow |
| +// UPDATE the provision converge uses, for the same reason as SetGate: Upsert |
| +// preserves a set value and so could not clear one. canonical is |
| +// ratelimit.Settings.Canonical's output, "" for the defaults. Errors on an |
| +// unmatched host, SetBacking's contract. |
| +func (r *Registry) SetRateLimit(host, canonical string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetRateLimit", Host: host, RateLimit: canonical}, nil) |
| + return err |
| + } |
| + |
| + res, err := r.sql.Exec(`UPDATE routes SET rate_limit = ? WHERE host = ?`, canonical, host) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return fmt.Errorf("no route for host %q", host) |
| + } |
| + return nil |
| +} |
| + |
| // SetChannels repoints every host in hosts to channel as ONE transaction — |
| // every row moves or none do. `carlos route -app` uses this for its bulk |
| // form: the caller runs every route's safety-rail check to completion FIRST |
| @@ -3068,7 +3106,7 @@ func (r *Registry) Fingerprint() (string, error) { |
| } |
| rows, err := db.Query(` |
| SELECT rowid, status, socket, addr, version, hibernate, channel, backing, tunnel, |
| - db_path, region, fleet_box, addrs, health, gate, hold_op, import_gate, |
| + db_path, region, fleet_box, addrs, health, gate, hold_op, import_gate, rate_limit, |
| claim_incarnation, claim_stamp, claim_owner, claim_row_id |
| FROM routes ORDER BY rowid`) |
| if err != nil { |
| @@ -3080,9 +3118,9 @@ FROM routes ORDER BY rowid`) |
| for rows.Next() { |
| var rowid, hibernate int64 |
| var claim ClaimOwnership |
| - var status, socket, addr, version, channel, backing, tunnel, dbPath, region, fleetBox, addrs, health, gate, holdOp, importGate string |
| + var status, socket, addr, version, channel, backing, tunnel, dbPath, region, fleetBox, addrs, health, gate, holdOp, importGate, rateLimit string |
| if err := rows.Scan(&rowid, &status, &socket, &addr, &version, &hibernate, |
| - &channel, &backing, &tunnel, &dbPath, ®ion, &fleetBox, &addrs, &health, &gate, &holdOp, &importGate, |
| + &channel, &backing, &tunnel, &dbPath, ®ion, &fleetBox, &addrs, &health, &gate, &holdOp, &importGate, &rateLimit, |
| &claim.ClaimIncarnation, &claim.ClaimStamp, &claim.ClaimOwner, &claim.ClaimRowID); err != nil { |
| return "", err |
| } |
| @@ -3102,6 +3140,12 @@ FROM routes ORDER BY rowid`) |
| if importGate != "" { |
| fmt.Fprintf(h, "\x1fimport_gate=%q", importGate) |
| } |
| + // So does the rate limit: the edge parses it at reload, and a registry |
| + // where no instance has custom limits digests as it did before the |
| + // column existed. |
| + if rateLimit != "" { |
| + fmt.Fprintf(h, "\x1frate_limit=%q", rateLimit) |
| + } |
| // Ownership follows the same rule: the all-default value is omitted, |
| // and each non-default field is tagged and quoted so a partial value |
| // cannot alias another field's. |
| @@ -3309,6 +3353,7 @@ var snapshotWhenSetCols = map[string]bool{ |
| "good_digest": true, |
| "good_demand": true, |
| "update_settings": true, |
| + "rate_limit": true, |
| } |
| func (r *Registry) SnapshotFingerprint() (string, error) { |
diff --git a/internal/registry/remote_dispatch.go b/internal/registry/remote_dispatch.go| index 23adccb5e..3c2331df7 100644 |
| --- a/internal/registry/remote_dispatch.go |
| +++ b/internal/registry/remote_dispatch.go |
| @@ -60,6 +60,7 @@ type remoteRequest struct { |
| Replicas string |
| Replica bool |
| Hash string |
| + RateLimit string |
| Hosts []string |
| Env string |
| Tunnel string |
| @@ -251,6 +252,8 @@ func (r *Registry) dispatchRemote(ctx context.Context, q remoteRequest, callback |
| err = r.SetReplica(q.Host, q.Replica) |
| case "SetGate": |
| err = r.SetGate(q.Host, q.Hash) |
| + case "SetRateLimit": |
| + err = r.SetRateLimit(q.Host, q.RateLimit) |
| case "SetChannels": |
| err = r.SetChannels(q.Hosts, q.Channel) |
| case "SetEnvironment": |