Skip to content

Commit ae63b94

Browse files
committed
feat(database): tunable rls.sessionVariable; raise oboPoolMax to 100, drop user pool max to 2
1 parent 707f68c commit ae63b94

5 files changed

Lines changed: 110 additions & 21 deletions

File tree

packages/appkit/src/plugins/database/defaults.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ export const STATEMENT_TIMEOUT_DEFAULT_MS = 15_000;
1414
/** `application_name` per connection — surfaces in `pg_stat_activity`/Lakebase audit. */
1515
export const APPLICATION_NAME = "appkit:database";
1616

17+
/** GUC name AppKit `SET`s on every OBO connection for RLS policies to read. */
18+
export const DEFAULT_RLS_SESSION_VARIABLE = "app.user_id";
19+
1720
/**
18-
* OBO pool defaults — small (one pool per user). Fan-out = `(1 + oboPoolMax) × max`;
19-
* defaults cap at `(1+25)×4 + 10 ≈ 114` conns per instance.
21+
* OBO pool defaults. `max=2` because a single user typically serializes HTTP
22+
* requests; 2 conns covers occasional overlap without bloating fan-out.
23+
* Combined with `oboPoolMax=100`, fan-out is `(1+100)×2 + 10 ≈ 212` conns.
2024
*/
2125
export const OBO_POOL_DEFAULTS = {
2226
...POOL_DEFAULTS,
23-
max: 4,
27+
max: 2,
2428
};
2529

2630
/** Default page size when no `?limit=` is given. */

packages/appkit/src/plugins/database/entity-wiring.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { AuthenticationError, ConfigurationError } from "@/errors";
1313
import { createLogger } from "@/logging/logger";
1414
import {
1515
APPLICATION_NAME,
16+
DEFAULT_RLS_SESSION_VARIABLE,
1617
OBO_POOL_DEFAULTS,
1718
STATEMENT_TIMEOUT_DEFAULT_MS,
1819
} from "./defaults";
@@ -160,14 +161,14 @@ function makeUserPoolRegistry(
160161
user: identity.email,
161162
workspaceClient: createUserWorkspaceClient(identity.token),
162163
});
163-
// Session-local `app.user_id` so `current_user_id()` RLS helpers resolve
164-
// to the OBO user — safe at session scope since identity is invariant in
165-
// this per-user pool. `statement_timeout` set here too so OBO queries get
166-
// the same server-side cap as SP ones.
164+
// Session-local GUC so RLS helpers resolve to the OBO user — safe at
165+
// session scope since identity is invariant in this per-user pool.
166+
// `statement_timeout` set here too so OBO matches SP server-side cap.
167167
const statementTimeoutMs =
168168
config.statementTimeoutMs ?? STATEMENT_TIMEOUT_DEFAULT_MS;
169+
const sessionVariable =
170+
config.rls?.sessionVariable ?? DEFAULT_RLS_SESSION_VARIABLE;
169171
pool.on("connect", (client) => {
170-
// Tag OBO conns in pg_stat_activity so operators can split SP vs OBO traffic.
171172
client
172173
.query(`SET application_name = '${APPLICATION_NAME}:obo'`)
173174
.catch((err) => {
@@ -178,10 +179,14 @@ function makeUserPoolRegistry(
178179
);
179180
});
180181
client
181-
.query("SELECT set_config('app.user_id', $1, false)", [identity.email])
182+
.query("SELECT set_config($1, $2, false)", [
183+
sessionVariable,
184+
identity.email,
185+
])
182186
.catch((err) => {
183187
logger.error(
184-
"Failed to set app.user_id on user pool connection for %s: %O",
188+
"Failed to set %s on user pool connection for %s: %O",
189+
sessionVariable,
185190
tag,
186191
err,
187192
);
@@ -249,7 +254,7 @@ function resolveUserPoolIdentity(
249254
if (email && token) return { email, token };
250255

251256
if (isDev) {
252-
logger.warn(
257+
logger.debug(
253258
"Database OBO requested without x-forwarded-email/x-forwarded-access-token; falling back to service pool in development.",
254259
);
255260
return null;
@@ -275,9 +280,10 @@ function createUserWorkspaceClient(token: string): WorkspaceClient {
275280
}
276281

277282
function normalizePoolMax(value: number | undefined): number {
278-
// Default 25 keeps fan-out tractable on Lakebase tiers ((1+25)×4 + SP(10)
279-
// ≈ 114 conns). Hot-OBO apps should raise explicitly after sizing the tier.
280-
if (!Number.isFinite(value) || value === undefined) return 25;
283+
// Default 100 active users per instance before LRU evicts; with
284+
// OBO_POOL_DEFAULTS.max=2, fan-out is (1+100)×2 + SP(10) ≈ 212 conns.
285+
// Sized for 1+ CU Lakebase tiers; tune up for hot OBO, down for 0.5 CU.
286+
if (!Number.isFinite(value) || value === undefined) return 100;
281287
return Math.max(1, Math.floor(value));
282288
}
283289

packages/appkit/src/plugins/database/manifest.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,20 @@
8282
},
8383
"oboPoolMax": {
8484
"type": "number",
85-
"default": 25,
86-
"description": "Maximum number of per-user OBO pools to keep open. Worst-case fan-out is (1 + oboPoolMax) × OBO_POOL_DEFAULTS.max + POOL_DEFAULTS.max connections per app instance."
85+
"default": 100,
86+
"description": "Maximum number of per-user OBO pools to keep open. Worst-case fan-out is (1 + oboPoolMax) × OBO_POOL_DEFAULTS.max + POOL_DEFAULTS.max connections per app instance (default 212)."
87+
},
88+
"rls": {
89+
"type": "object",
90+
"additionalProperties": false,
91+
"description": "Row-level security tunables.",
92+
"properties": {
93+
"sessionVariable": {
94+
"type": "string",
95+
"default": "app.user_id",
96+
"description": "GUC name AppKit SETs on every OBO connection. Override to align with existing policies that read another setting."
97+
}
98+
}
8799
},
88100
"cache": {
89101
"type": "object",

packages/appkit/src/plugins/database/tests/plugin.test.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -496,8 +496,67 @@ describe("DatabasePlugin", () => {
496496
const client = { query: vi.fn(async () => ({})) };
497497
handler(client);
498498
expect(client.query).toHaveBeenCalledWith(
499-
"SELECT set_config('app.user_id', $1, false)",
500-
["alice@example.com"],
499+
"SELECT set_config($1, $2, false)",
500+
["app.user_id", "alice@example.com"],
501+
);
502+
503+
if (originalHost === undefined) {
504+
delete process.env.DATABRICKS_HOST;
505+
} else {
506+
process.env.DATABRICKS_HOST = originalHost;
507+
}
508+
});
509+
510+
test("rls.sessionVariable override flows into the SET on user pool connect", async () => {
511+
const originalHost = process.env.DATABRICKS_HOST;
512+
process.env.DATABRICKS_HOST = "https://example.cloud.databricks.com";
513+
const servicePool = {
514+
end: vi.fn(async () => undefined),
515+
on: vi.fn(),
516+
} as unknown as Pool;
517+
const userPool = {
518+
end: vi.fn(async () => undefined),
519+
on: vi.fn(),
520+
} as unknown as Pool;
521+
vi.mocked(createLakebasePool)
522+
.mockReturnValueOnce(servicePool)
523+
.mockReturnValueOnce(userPool);
524+
const schema = defineSchema(({ table }) => ({
525+
user: table("user", { id: id(), email: text().notNull() }),
526+
}));
527+
vi.mocked(loadSchemaByConvention).mockResolvedValue({
528+
schema,
529+
schemaPath: "/app/config/database/schema.ts",
530+
});
531+
532+
const plugin = createPlugin({
533+
rls: { sessionVariable: "myapp.uid" },
534+
});
535+
await plugin.setup();
536+
const exports = plugin.exports() as unknown as {
537+
user: { asUser: (req: import("express").Request) => unknown };
538+
};
539+
const req = {
540+
header: vi.fn((name: string) => {
541+
if (name === "x-forwarded-email") return "alice@example.com";
542+
if (name === "x-forwarded-access-token") return "tok-alice";
543+
return undefined;
544+
}),
545+
} as unknown as import("express").Request;
546+
exports.user.asUser(req);
547+
548+
const handler = vi
549+
.mocked(userPool.on)
550+
.mock.calls.find(
551+
([event]) => event === "connect",
552+
)?.[1] as unknown as (client: {
553+
query: ReturnType<typeof vi.fn>;
554+
}) => void;
555+
const client = { query: vi.fn(async () => ({})) };
556+
handler(client);
557+
expect(client.query).toHaveBeenCalledWith(
558+
"SELECT set_config($1, $2, false)",
559+
["myapp.uid", "alice@example.com"],
501560
);
502561

503562
if (originalHost === undefined) {

packages/appkit/src/plugins/database/types.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,9 @@ export interface IDatabaseConfig extends BasePluginConfig {
155155
cache?: CacheSettings;
156156
/**
157157
* Maximum number of distinct per-user (OBO) pools the registry keeps alive
158-
* at once. Each pool defaults to `OBO_POOL_DEFAULTS.max = 4` connections, so
159-
* the worst-case fan-out is `(1 + oboPoolMax) × poolMax`. Defaults to 25
160-
* tune up for hot OBO traffic, down for low-tier Lakebase plans.
158+
* at once. Each pool defaults to `OBO_POOL_DEFAULTS.max = 2` connections, so
159+
* worst-case fan-out is `(1 + oboPoolMax) × poolMax + 10`. Defaults to 100
160+
* tune up for hot OBO traffic, down for 0.5 CU Lakebase tiers.
161161
*/
162162
oboPoolMax?: number;
163163
/**
@@ -166,6 +166,14 @@ export interface IDatabaseConfig extends BasePluginConfig {
166166
* timeout interceptor still applies on the client side.
167167
*/
168168
statementTimeoutMs?: number;
169+
/** Row-level security tunables. */
170+
rls?: {
171+
/**
172+
* GUC name AppKit `SET`s on every OBO connection. Override to align with
173+
* existing policies that read another setting. Defaults to `app.user_id`.
174+
*/
175+
sessionVariable?: string;
176+
};
169177
/**
170178
* When true, schema-load and drift-check failures during `setup()` are
171179
* logged but do not throw. Defaults to false (fail closed). Useful in

0 commit comments

Comments
 (0)