carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 1649004055dd869860b8fdb5dada01e5a86b9901 parent fac7cc7
8 files changed, +453 −7
  • internal/provision/provision.go +25 −0
  • internal/provision/ratelimit_record_test.go +93 −0
  • internal/provision/reconcile.go +19 −0
  • internal/provision/reconcile_ratelimit_test.go +102 −0
  • internal/registry/ratelimit_column_test.go +122 −0
  • internal/registry/ratelimit_fingerprint_test.go +37 −0
  • internal/registry/registry.go +52 −7
  • internal/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, &region, &fleetBox, &addrs, &health, &gate, &holdOp, &importGate,
+ &channel, &backing, &tunnel, &dbPath, &region, &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":