Skip to content

Commit f2ba915

Browse files
valvesssclaude
andcommitted
HAULDR-8: o envelope do perfil chega no PostgREST, e nessa ordem
PGRST_DB_POOL nunca foi setado — em lugar nenhum do repo. Todo sidecar rest roda o default do PostgREST (pool 10) e, medido na frota, pica em 11 backends com a conexão de LISTEN. O poolSize do perfil só era escrito no Supavisor, que só governa os 8 sidecars roteados por ele; os outros 20 vão direto ao Postgres, onde o único knob que sobra é o CONNECTION LIMIT do role. O Nano limita em 8. 8 < 11 ⇒ o role não throttla, ele RECUSA: aplicando o envelope Nano no logcheck_dev, 8 de 40 requisições viraram 503. Ou seja, "dev nasce nano" (HAULDR-6) embarcou um 503 latente — não mordeu só porque nenhum projeto foi rebaixado ainda. provisionRest passa a derivar PGRST_DB_POOL do perfil do projeto, que é o número que o poolSize sempre quis dizer. Com isso pool+LISTEN cabe folgado em todos os envelopes: nano 2+1<8, micro 5+1<20, small 10+1<40. setProjectProfile passa a propagar o pool pro sidecar, e a ORDEM é a correção: teto e pool empurram um contra o outro, então encolhe o pool antes de baixar o teto, e sobe o teto antes de crescer o pool. Ao encolher, falhar aborta antes de baixar o teto (teto velho é seguro); ao crescer, falhar só deixa o pool menor que o permitido (conservador). ⚠️ Os sidecars já provisionados seguem com o default 10 até o próximo provision — seguros (11 < 20 do micro), mas ainda desonestos. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6c5d910 commit f2ba915

3 files changed

Lines changed: 61 additions & 13 deletions

File tree

control-plane/src/coolify.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ export async function coolifyProvisionRest(
312312
base: string,
313313
environment: string,
314314
publicDataPlane = false,
315+
dbPool = 10,
315316
): Promise<CoolifyRestEndpoint> {
316317
const { domain: restUrl, domains } = await ensureEndpoint(
317318
base,
@@ -341,6 +342,12 @@ export async function coolifyProvisionRest(
341342
PGRST_SERVER_PORT: "3000",
342343
// The public URL, so the served OpenAPI advertises the right base.
343344
PGRST_OPENAPI_SERVER_PROXY_URI: restUrl,
345+
// The profile's envelope, applied where it actually binds. Left unset,
346+
// PostgREST defaults to a pool of 10 and (measured) peaks at 11 backends
347+
// with the LISTEN connection — over Nano's connection limit of 8, so the
348+
// role refuses and requests 503. Supavisor only bounds the sidecars routed
349+
// through it; most go direct, where this is the sole enforcement point.
350+
PGRST_DB_POOL: String(dbPool),
344351
};
345352
await setEnvs(appUuid, env);
346353
await deployApp(appUuid);

control-plane/src/postgrest.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { clusterOf, dbHostForCluster, dbPortForCluster, type Cluster } from "./c
66
import { coolifyProvisionRest, coolifyDestroyRest } from "./coolify";
77
import { projectIdentity } from "./gotrue";
88
import { resolveSidecarBaseUrl } from "./sidecar";
9+
import { DEFAULT_PROFILE, envelope, isProjectProfile } from "./profiles";
910

1011
const exec = promisify(execFile);
1112

@@ -43,14 +44,20 @@ function restDbUri(
4344
*/
4445
async function prepareRest(
4546
name: string,
46-
): Promise<{ database: string; dbUri: string; jwtSecret: string }> {
47+
): Promise<{ database: string; dbUri: string; jwtSecret: string; dbPool: number }> {
4748
const cluster = await clusterOf(name);
4849
const { rows } = await controlPool.query(
49-
"select database, role, db_password, jwt_secret from projects where name = $1",
50+
"select database, role, db_password, jwt_secret, profile from projects where name = $1",
5051
[name],
5152
);
5253
const row = rows[0] as
53-
| { database: string; role: string; db_password: string | null; jwt_secret: string | null }
54+
| {
55+
database: string;
56+
role: string;
57+
db_password: string | null;
58+
jwt_secret: string | null;
59+
profile: string | null;
60+
}
5461
| undefined;
5562
if (!row) throw new Error(`project '${name}' does not exist`);
5663
if (!row.db_password) throw new Error(`project '${name}' has no stored db password — re-provision it`);
@@ -59,10 +66,12 @@ async function prepareRest(
5966
`project '${name}' has no JWT secret — provision auth (GoTrue) before REST`,
6067
);
6168
}
69+
const profile = isProjectProfile(row.profile) ? row.profile : DEFAULT_PROFILE;
6270
return {
6371
database: row.database,
6472
dbUri: restDbUri(row.database, row.role, row.db_password, cluster),
6573
jwtSecret: row.jwt_secret,
74+
dbPool: envelope(profile).poolSize,
6675
};
6776
}
6877

@@ -76,14 +85,14 @@ async function prepareRest(
7685
* run and route it. Idempotent on the app/container.
7786
*/
7887
export async function provisionRest(name: string): Promise<ProjectRest> {
79-
const { dbUri, jwtSecret } = await prepareRest(name);
88+
const { dbUri, jwtSecret, dbPool } = await prepareRest(name);
8089

8190
let endpoint: ProjectRest;
8291
if (config.restProvisioner === "coolify") {
8392
const { base, env, publicDataPlane } = await projectIdentity(name);
84-
endpoint = await coolifyProvisionRest(name, dbUri, jwtSecret, base, env, publicDataPlane);
93+
endpoint = await coolifyProvisionRest(name, dbUri, jwtSecret, base, env, publicDataPlane, dbPool);
8594
} else {
86-
endpoint = await dockerProvisionRest(name, dbUri, jwtSecret);
95+
endpoint = await dockerProvisionRest(name, dbUri, jwtSecret, dbPool);
8796
}
8897

8998
await controlPool.query(
@@ -124,6 +133,7 @@ async function dockerProvisionRest(
124133
name: string,
125134
dbUri: string,
126135
jwtSecret: string,
136+
dbPool: number,
127137
): Promise<ProjectRest> {
128138
const container = `hauldr-rest-${name}`;
129139
await docker(["rm", "-f", container]).catch(() => {});
@@ -139,6 +149,7 @@ async function dockerProvisionRest(
139149
"-e", "PGRST_JWT_AUD=authenticated",
140150
"-e", "PGRST_DB_USE_LEGACY_GUCS=false",
141151
"-e", "PGRST_SERVER_PORT=3000",
152+
"-e", `PGRST_DB_POOL=${dbPool}`,
142153
config.restImage,
143154
]);
144155

control-plane/src/profileapi.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
poolerEnabled,
1414
registerTenant,
1515
} from "./supavisor";
16+
import { provisionRest } from "./postgrest";
1617

1718
function ident(s: string) {
1819
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s)) throw new Error(`bad ident: ${s}`);
@@ -23,8 +24,15 @@ function lit(s: string) {
2324
}
2425

2526
/**
26-
* Apply a profile envelope to the project's authenticator role + Supavisor
27-
* pool_size, and persist `projects.profile`. Idempotent.
27+
* Apply a profile envelope to the project's authenticator role, its PostgREST
28+
* pool and the Supavisor pool_size, and persist `projects.profile`. Idempotent.
29+
*
30+
* The envelope has two halves that push against each other: the role's
31+
* CONNECTION LIMIT is a ceiling, and the sidecar's pool is what climbs it. They
32+
* must never cross — a pool above the ceiling doesn't get throttled, it gets
33+
* REFUSED, and the lane serves 503s. So the order below is load-bearing:
34+
* shrink the pool before lowering the ceiling, raise the ceiling before growing
35+
* the pool. Same reason a gate's two halves can't land apart.
2836
*/
2937
export async function setProjectProfile(
3038
name: string,
@@ -33,7 +41,8 @@ export async function setProjectProfile(
3341
if (!isProjectProfile(profile)) throw new Error(`invalid profile: ${profile}`);
3442

3543
const { rows } = await controlPool.query(
36-
`select name, role, database, db_password, tenant_external_id, profile
44+
`select name, role, database, db_password, tenant_external_id, profile,
45+
postgrest_url, rest_requested
3746
from projects where name = $1`,
3847
[name],
3948
);
@@ -45,11 +54,28 @@ export async function setProjectProfile(
4554
db_password: string | null;
4655
tenant_external_id: string | null;
4756
profile: string;
57+
postgrest_url: string | null;
58+
rest_requested: boolean | null;
4859
}
4960
| undefined;
5061
if (!r) throw new Error(`project not found: ${name}`);
5162

5263
const env = envelope(profile);
64+
const previous = isProjectProfile(r.profile) ? r.profile : DEFAULT_PROFILE;
65+
const shrinking = env.connectionLimit < envelope(previous).connectionLimit;
66+
const hasRest = !!(r.postgrest_url || r.rest_requested);
67+
68+
// Persist first: provisionRest sizes PGRST_DB_POOL off this row.
69+
await controlPool.query(
70+
"update projects set profile = $2 where name = $1",
71+
[name, profile],
72+
);
73+
74+
// Shrinking: the pool has to come down FIRST, and a failure here must abort
75+
// before the ceiling drops — leaving the old (higher) ceiling is safe, the
76+
// reverse is an outage.
77+
if (shrinking && hasRest) await provisionRest(name);
78+
5379
const cluster = await clusterOf(name);
5480
const admin = adminClientForCluster(cluster);
5581
await admin.connect();
@@ -67,6 +93,14 @@ export async function setProjectProfile(
6793
await admin.end();
6894
}
6995

96+
// Growing: ceiling is already up, so the pool can follow. A failure here only
97+
// leaves the pool smaller than the envelope allows — conservative, not broken.
98+
if (!shrinking && hasRest) {
99+
await provisionRest(name).catch((e) => {
100+
console.warn(`[profile] ${name}: pool not resized to ${env.poolSize}: ${e}`);
101+
});
102+
}
103+
70104
if (poolerEnabled() && r.db_password && r.tenant_external_id) {
71105
await registerTenant({
72106
externalId: r.tenant_external_id,
@@ -78,10 +112,6 @@ export async function setProjectProfile(
78112
});
79113
}
80114

81-
await controlPool.query(
82-
"update projects set profile = $2 where name = $1",
83-
[name, profile],
84-
);
85115
return { name, profile };
86116
}
87117

0 commit comments

Comments
 (0)