Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions internal/backend/postgres/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ func k8sEnvInt(key string, fallback int) int {
// Backend is the interface every Postgres provisioning backend must implement.
type Backend interface {
// Provision creates the database and role for the given token.
// connLimit is the CONNECTION LIMIT to apply to the role (-1 = unlimited,
// 0 = unlimited). The caller (provisioner server) computes this from the
// shared plans.Registry so the provisioner stays a dumb executor.
// connLimit is the CONNECTION LIMIT to apply to the role; <= 0 is the "no cap"
// sentinel (Postgres treats both -1 and 0 as "no limit" here). The caller
// (provisioner server) computes this from the shared plans registry so the
// provisioner stays a dumb executor. Every tier's postgres_connections is finite
// post strict-80 (2026-06-05), so the registry does not pass the sentinel today.
Provision(ctx context.Context, token, tier string, connLimit int) (*Credentials, error)
StorageBytes(ctx context.Context, token, providerResourceID string) (int64, error)
Deprovision(ctx context.Context, token, providerResourceID string) error
Expand Down
7 changes: 6 additions & 1 deletion internal/backend/postgres/k8s.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,12 @@ func sizingForTier(tier string) tierSizing {
pvcGi: 200,
qCPURequests: "1", qMemRequests: "4Gi",
qCPULimits: "8", qMemLimits: "16Gi",
connLimit: -1, // unlimited; capped only by pod max_connections
// "no cap" sizing default; the enforced cap comes from the registry via
// Regrade (plans.yaml: team postgres_connections=100, growth=20 — both
// finite post strict-80, 2026-06-05). Note sz.connLimit is also what
// Provision's initDatabase uses, so the role is briefly uncapped until the
// reconciler's first Regrade applies the finite registry value.
connLimit: -1,
}
default:
// Unknown tier → conservative hobby-equivalent sizing rather than fail-open.
Expand Down
15 changes: 10 additions & 5 deletions internal/backend/postgres/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,12 @@ func generatePassword(n int) (string, error) {
}

// Provision creates a Postgres database and user for the given token.
// connLimit is the CONNECTION LIMIT to apply to the role; -1 means unlimited
// (omits the clause). This value comes from the API handler via plans.Registry
// so the provisioner stays a dumb executor and the API remains the policy owner.
// connLimit is the CONNECTION LIMIT to apply to the role; <= 0 is the "no cap"
// sentinel (omits the clause; Postgres treats -1 as "no limit"). This value comes
// from the plans registry so the provisioner stays a dumb executor and the policy
// owner sets the cap. Every tier's postgres_connections is finite post the strict-80
// margin redesign (2026-06-05), so the registry does not pass the sentinel today —
// it is retained for the wire contract.
func (b *LocalBackend) Provision(ctx context.Context, token, tier string, connLimit int) (*Credentials, error) {
dbName := dbNamePrefix + token
username := userNamePrefix + token
Expand Down Expand Up @@ -374,8 +377,10 @@ func (b *LocalBackend) Deprovision(ctx context.Context, token, providerResourceI
// user on the shared local Postgres cluster. This is called by the entitlement
// reconciler on plan upgrades/downgrades to ensure the role cap matches the new tier.
//
// connLimit <= 0 means unlimited (pass -1 from plans.Registry). Postgres uses -1
// internally for "no limit"; ALTER ROLE with CONNECTION LIMIT -1 removes any cap.
// connLimit <= 0 is the "no cap" sentinel. Postgres uses -1 internally for "no
// limit"; ALTER ROLE with CONNECTION LIMIT -1 removes any cap. The registry returns
// a finite value for every tier post strict-80 (2026-06-05), so this branch is the
// wire contract, not a path any current tier exercises.
func (b *LocalBackend) Regrade(ctx context.Context, token, providerResourceID string, connLimit int) (RegradeResult, error) {
// P0-2: a pool-claimed role is named from the pool token; resolve it from
// provider_resource_id so ALTER ROLE targets the role that actually exists.
Expand Down
4 changes: 3 additions & 1 deletion internal/backend/redis/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ type Backend interface {
// without error, and the reconciler leaves the row for the next sweep.
type Regrader interface {
// Regrade connects to the dedicated Redis pod and adjusts maxmemory to
// match targetMaxmemoryMB. targetMaxmemoryMB <= 0 means unlimited (maxmemory 0).
// match targetMaxmemoryMB. targetMaxmemoryMB <= 0 is the "no cap" sentinel
// (maxmemory 0); every tier's redis_memory_mb is finite post strict-80
// (2026-06-05), so the registry does not pass the sentinel today.
// Returns RegradeResult.Applied=false + SkipReason when the pod is already
// correctly configured (idempotent no-op) or unreachable (soft skip).
Regrade(ctx context.Context, token, providerResourceID string, targetMaxmemoryMB int) (RegradeResult, error)
Expand Down
54 changes: 34 additions & 20 deletions internal/backend/redis/k8s.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,11 @@ const (
// instead of "allkeys-lru" silently evicting the customer's oldest keys.
// Silent eviction also contradicts --appendonly yes (durability). See P1-C.
redisMaxmemoryPolicyCapped = "noeviction"
// redisMaxmemoryPolicyUnlimited is the policy for unlimited tiers (team):
// no cap, so eviction never triggers; "noeviction" is Redis's default.
// redisMaxmemoryPolicyUnlimited is the policy used when a tier resolves to the
// "no cap" sentinel (maxmemoryMB <= 0): no cap, so eviction never triggers;
// "noeviction" is Redis's default. Post the strict-80 margin redesign
// (2026-06-05) every tier's redis_memory_mb is finite, so no current tier uses
// this policy — it is retained for the sentinel contract.
redisMaxmemoryPolicyUnlimited = "noeviction"

// redisK8sOwnerTeamLabel is applied to dedicated customer namespaces.
Expand Down Expand Up @@ -137,14 +140,19 @@ func routeKeyTTLForTier(tier string) time.Duration {
// the default user (no ACL), so pod-level is the natural lever. Since each pod is
// dedicated to one customer this is functionally a per-customer cap.
//
// maxmemoryMB is the Redis --maxmemory flag value in MB. A value of -1 means
// unlimited (the flag is omitted entirely). This enforces the per-resource memory
// limit advertised in plans.yaml at the Redis level. The noeviction policy is used
// (see redisMaxmemoryPolicyCapped) so writes fail loudly with an OOM error at the
// cap rather than silently evicting customer data. Values mirror plans.yaml
// redis_memory_mb:
// maxmemoryMB is the Redis --maxmemory flag value in MB. A value of <= 0 is the
// "no cap" sentinel (the flag is omitted entirely). This enforces the per-resource
// memory limit at the Redis level. The noeviction policy is used (see
// redisMaxmemoryPolicyCapped) so writes fail loudly with an OOM error at the cap
// rather than silently evicting customer data. These pod-start values are the
// dedicated-pod sizing defaults; the runtime-enforced cap is reconciled to the
// plans registry by RegradeResource (server.go) on provision and plan change.
//
// anonymous: 5 MB, hobby: 50 MB, pro: 512 MB, growth: 1024 MB, team: unlimited (-1)
// anonymous: 5 MB, hobby: 50 MB, pro: 512 MB, growth: 1024 MB, team: -1 (no flag)
//
// NOTE: the registry's redis_memory_mb is finite for every tier post the strict-80
// margin redesign (2026-06-05; team=1536), so the team -1 below is a pod-start
// default that Regrade overrides — it no longer mirrors plans.yaml.
type tierSizing struct {
cpuReq, memReq string
cpuLim, memLim string
Expand All @@ -156,7 +164,9 @@ type tierSizing struct {
// Bounds total simultaneous TCP clients connected to this pod.
maxClients int
// maxmemoryMB is the Redis --maxmemory limit in MB applied at pod start.
// -1 means unlimited (flag omitted). Mirrors plans.yaml redis_memory_mb.
// <= 0 is the "no cap" sentinel (flag omitted). The pod-start value is a sizing
// default; the runtime cap is reconciled to the plans registry by Regrade. The
// registry's redis_memory_mb is finite for every tier post strict-80 (2026-06-05).
// The noeviction maxmemory-policy is applied alongside this limit (P1-C).
maxmemoryMB int
}
Expand Down Expand Up @@ -225,15 +235,17 @@ func sizingForTier(tier string) tierSizing {
maxmemoryMB: 1024, // plans.yaml: growth redis_memory_mb = 1024
}
case "team", "team_yearly":
// team_yearly mirrors team (plans.yaml: identical -1 limits, annual billing).
// team_yearly mirrors team (identical limits, annual billing).
return tierSizing{
cpuReq: "500m", memReq: "1Gi",
cpuLim: "4", memLim: "4Gi",
pvcMi: 51200, // 50Gi
qCPURequests: "1", qMemRequests: "2Gi",
qCPULimits: "8", qMemLimits: "8Gi",
maxClients: 1000,
maxmemoryMB: -1, // unlimited — team dedicated pods have no memory cap
maxClients: 1000,
// "no cap" pod-start default; Regrade reconciles to the registry value
// (plans.yaml: team redis_memory_mb = 1536, finite post strict-80).
maxmemoryMB: -1,
}
default:
// Unknown tier → conservative hobby-equivalent sizing.
Expand Down Expand Up @@ -624,8 +636,9 @@ type RegradeResult struct {
// - targetMaxmemoryMB > 0 → set maxmemory to that many MB + noeviction policy
// (P1-C: writes fail loudly at the cap, no silent eviction), then CONFIG
// REWRITE so the cap survives a pod restart.
// - targetMaxmemoryMB <= 0 → unlimited tier (team/growth): set maxmemory to 0
// (Redis "no cap") + CONFIG REWRITE so it explicitly overrides any leftover cap.
// - targetMaxmemoryMB <= 0 → "no cap" sentinel: set maxmemory to 0 (Redis "no
// cap") + CONFIG REWRITE so it explicitly overrides any leftover cap. No current
// tier resolves here post strict-80 (2026-06-05) — every redis_memory_mb is finite.
//
// Idempotent: reads CONFIG GET maxmemory first and short-circuits if the value
// already matches, returning Applied=false + SkipReason="already correct".
Expand Down Expand Up @@ -851,7 +864,7 @@ func (b *K8sBackend) regradeViaExec(ctx context.Context, ns, token string, targe
return RegradeResult{Applied: false, SkipReason: "exec fallback: CONFIG GET output unparseable"}, nil
}

// Compute target bytes (0 = unlimited for team tier).
// Compute target bytes (targetMaxmemoryMB <= 0 → 0 bytes = Redis "no cap").
var targetBytes int64
if targetMaxmemoryMB > 0 {
targetBytes = int64(targetMaxmemoryMB) * 1024 * 1024
Expand Down Expand Up @@ -1104,10 +1117,11 @@ func (b *K8sBackend) applyDeployment(ctx context.Context, ns string, sz tierSizi
"--dir", "/data",
"--maxclients", fmt.Sprintf("%d", sz.maxClients),
}
// Only add --maxmemory when the tier has a defined cap.
// -1 means unlimited (team/growth) — omit the flag so Redis
// uses its default (no cap). This matches plans.yaml semantics
// where -1 = unlimited.
// Only add --maxmemory when the sizing has a defined cap.
// sz.maxmemoryMB <= 0 is the "no cap" sentinel — omit the
// flag so Redis uses its default (no cap). This is a pod-start
// default; Regrade later reconciles the cap to the registry
// value, which is finite for every tier post strict-80.
if sz.maxmemoryMB > 0 {
cmd = append(cmd,
"--maxmemory", fmt.Sprintf("%dmb", sz.maxmemoryMB),
Expand Down
4 changes: 2 additions & 2 deletions internal/backend/redis/k8s_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ func TestSizingForTier_MaxmemoryMB_MatchesPlansYAML(t *testing.T) {
{"hobby_plus_yearly", 50, true}, // plans.yaml: hobby_plus_yearly mirrors hobby_plus
{"pro", 512, true}, // plans.yaml: pro redis_memory_mb = 512
{"pro_yearly", 512, true},// plans.yaml: pro_yearly mirrors pro
{"team", -1, false}, // unlimited — flag omitted
{"team_yearly", -1, false}, // plans.yaml: team_yearly mirrors team (unlimited)
{"team", -1, false}, // "no cap" pod-start default — flag omitted; Regrade reconciles to registry (team=1536, finite post strict-80)
{"team_yearly", -1, false}, // team_yearly mirrors team's pod-start sizing default
{"growth", 1024, true}, // plans.yaml: growth redis_memory_mb = 1024
{"unknown", 50, true}, // unknown → hobby fallback
}
Expand Down
15 changes: 15 additions & 0 deletions internal/server/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package server

import "instant.dev/common/plans"

// SwapRegradeConnLimits temporarily replaces the package-level plans registry
// used by RegradeResource and returns a restore func. It exists so external
// tests can drive the "no cap" sentinel branch in regradeRedis (a tier whose
// redis_memory_mb resolves to <= 0), which no current production tier triggers
// post the strict-80 margin redesign — the branch is kept for the wire
// contract, so it needs an explicit registry-seam test rather than a live tier.
func SwapRegradeConnLimits(r *plans.Registry) (restore func()) {
prev := regradeConnLimits
regradeConnLimits = r
return func() { regradeConnLimits = prev }
}
22 changes: 16 additions & 6 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
// 2. DEDICATED_POSTGRES_DSN / NEON_API_KEY → Neon or local admin DSN
// 3. nil → shared backend used for all tiers
func New(cfg *config.Config, poolMgr *pool.Manager) *Server {
var dedPG postgres.Backend

Check warning on line 101 in internal/server/server.go

View workflow job for this annotation

GitHub Actions / typos

"ded" should be "dead".
var dedRedis redis.Backend
var dedMongo mongo.Backend
var dedQueue queue.Backend
Expand Down Expand Up @@ -378,7 +378,11 @@

func (s *Server) provisionPostgres(ctx context.Context, req *provisionerv1.ProvisionRequest) (*provisionerv1.ProvisionResponse, error) {
// Resolve the connection limit for this tier from the shared plans registry.
// -1 = unlimited (team/growth); > 0 = enforced cap at the Postgres role level.
// <= 0 is the wire sentinel for "no role-level cap" (Postgres uses -1 for "no
// limit"); > 0 is an enforced cap at the Postgres role level. Post the strict-80
// margin redesign (2026-06-05) every tier's postgres_connections is finite
// (e.g. pro=20, growth=20, team=100), so no tier passes the sentinel today — the
// branch is kept for the wire contract, not because any current tier uses it.
// The provisioner owns the plans registry so the cap stays consistent with
// what RegradeResource applies on plan upgrades.
connLimit := regradeConnLimits.ConnectionsLimit(req.Tier, "postgres")
Expand Down Expand Up @@ -1031,8 +1035,10 @@
// redisBackend does not implement redis.Regrader) return {applied:false} gracefully so
// the shared redis-provision pod is never accidentally capped.
//
// Team/growth tiers (memory limit = -1 in plans.yaml) set maxmemory=0 (Redis "unlimited")
// so existing pods are never accidentally capped.
// A tier whose redis_memory_mb resolves to <= 0 sets maxmemory=0 (Redis "no cap").
// Post the strict-80 margin redesign (2026-06-05) every tier's redis_memory_mb is
// finite in plans.yaml (e.g. pro=512, growth=1024, team=1536), so no tier hits this
// branch today — it remains for the sentinel contract, not for any current tier.
func (s *Server) regradeRedis(ctx context.Context, req *provisionerv1.RegradeRequest) (*provisionerv1.RegradeResponse, error) {
// Resolve the effective provider ID (k8s namespace).
// Case 1: prid is already "instant-customer-<something>" → use as-is.
Expand Down Expand Up @@ -1095,12 +1101,16 @@

// Memory cap comes from the shared plans registry.
// StorageLimitMB("redis") returns plans.yaml redis_memory_mb:
// anonymous=5, hobby=50, pro=512, team/growth=-1 (unlimited).
// -1 (unlimited) → targetMaxmemoryMB=0 → Regrade sets maxmemory=0 (no cap).
// anonymous=5, hobby=50, pro=512, growth=1024, team=1536.
// All current tiers are finite post the strict-80 margin redesign (2026-06-05).
// The < 0 branch below is the wire sentinel for "no cap" (-> targetMaxmemoryMB=0
// -> Regrade sets maxmemory=0); no current tier passes it, but it is kept so a
// future unlimited tier would behave correctly.
memLimitMB := regradeConnLimits.StorageLimitMB(req.Tier, "redis")
targetMaxmemoryMB := memLimitMB
if memLimitMB < 0 {
// Unlimited tier — explicitly clear any cap on the pod.
// "No cap" sentinel (no current tier passes it post strict-80) — explicitly
// clear any cap on the pod.
targetMaxmemoryMB = 0
}

Expand Down
Loading
Loading