Skip to content
Open
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
5 changes: 5 additions & 0 deletions docs/docs/api/appkit/Class.Plugin.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 18 additions & 1 deletion packages/appkit/src/core/plugin-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ interface RouteTarget {
type ToolProviderPlugin = BasePlugin &
ToolProvider & { asUser: (req: IAppRequest) => ToolProvider };

/**
* Lifecycle events emitted through {@link PluginContext.emitLifecycle}.
*
* - `"setup:complete"` — emitted by AppKit core after every plugin's
* `setup()` has finished.
* - `"server:ready"` — emitted when the HTTP server is listening.
* - `"shutdown"` — emitted by the server plugin during graceful shutdown,
* AFTER all plugin `shutdown()` hooks have completed and BEFORE remaining
* connections are force-closed. The emit is bounded by a short timeout
* (see the server plugin's shutdown budget), so subscribers must not
* start long-running async work — finish quickly or be cut off.
*/
type LifecycleEvent = "setup:complete" | "server:ready" | "shutdown";

/**
Expand Down Expand Up @@ -222,6 +234,10 @@ export class PluginContext {

/**
* Register a lifecycle hook callback.
*
* See {@link LifecycleEvent} for event semantics. In particular,
* `"shutdown"` subscribers run inside a bounded shutdown phase and must
* not start long-running async work.
*/
onLifecycle(event: LifecycleEvent, fn: () => void | Promise<void>): void {
let hooks = this.lifecycleHooks.get(event);
Expand All @@ -237,7 +253,8 @@ export class PluginContext {
* Errors in individual callbacks are logged but do not prevent
* other callbacks from running.
*
* @internal Called by AppKit core only.
* @internal Called by AppKit core (`setup:complete`) and the server
* plugin (`shutdown`, during graceful shutdown).
*/
async emitLifecycle(event: LifecycleEvent): Promise<void> {
const hooks = this.lifecycleHooks.get(event);
Expand Down
24 changes: 17 additions & 7 deletions packages/appkit/src/plugins/lakebase/lakebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,25 +169,35 @@ export class LakebasePlugin extends Plugin implements ToolProvider {

/**
* Gracefully drains and closes all connection pools (SP + OBO).
* Called automatically by AppKit during shutdown.
*
* Runs as the plugin's `shutdown()` hook (phase 3 of the server's graceful
* shutdown), NOT in `abortActiveOperations()` (phase 1): other plugins'
* `shutdown()` hooks may still need database connections to drain state,
* so the pools must outlive the abort phase. `pg.Pool#end()` waits for
* checked-out clients to be released, so hooks running concurrently with
* this one can still finish their in-flight queries. Errors are caught
* and logged; this hook never throws.
*/
abortActiveOperations(): void {
super.abortActiveOperations();
async shutdown(): Promise<void> {
if (this.pool) {
logger.info("Closing Lakebase SP pool");
this.pool.end().catch((err) => {
try {
await this.pool.end();
} catch (err) {
logger.error("Error closing Lakebase SP pool: %O", err);
});
}
this.pool = null;
}
if (this.oboPoolManager) {
logger.info(
"Closing all Lakebase OBO pools (%d)",
this.oboPoolManager.size,
);
this.oboPoolManager.closeAll().catch((err) => {
try {
await this.oboPoolManager.closeAll();
} catch (err) {
logger.error("Error closing Lakebase OBO pools: %O", err);
});
}
this.oboPoolManager = null;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,53 @@ describe("LakebasePlugin — readOnly enforcement", () => {
});
});

describe("LakebasePlugin — shutdown", () => {
test("closes the SP pool and all OBO pools via shutdown()", async () => {
const { createLakebasePool, createLakebasePoolManager } = await import(
"../../../connectors/lakebase"
);
const plugin = makePlugin({});
await plugin.setup();

const spPool = vi.mocked(createLakebasePool).mock.results.at(-1)?.value as {
end: ReturnType<typeof vi.fn>;
};
const oboManager = vi.mocked(createLakebasePoolManager).mock.results.at(-1)
?.value as { closeAll: ReturnType<typeof vi.fn> };

await plugin.shutdown();

expect(spPool.end).toHaveBeenCalledTimes(1);
expect(oboManager.closeAll).toHaveBeenCalledTimes(1);

// Idempotent: a second call has nothing left to close.
await plugin.shutdown();
expect(spPool.end).toHaveBeenCalledTimes(1);
expect(oboManager.closeAll).toHaveBeenCalledTimes(1);
});

test("abortActiveOperations() does NOT tear down the pools (teardown is shutdown()'s job)", async () => {
const { createLakebasePool, createLakebasePoolManager } = await import(
"../../../connectors/lakebase"
);
const plugin = makePlugin({});
await plugin.setup();

const spPool = vi.mocked(createLakebasePool).mock.results.at(-1)?.value as {
end: ReturnType<typeof vi.fn>;
};
const oboManager = vi.mocked(createLakebasePoolManager).mock.results.at(-1)
?.value as { closeAll: ReturnType<typeof vi.fn> };

plugin.abortActiveOperations();

// Other plugins' shutdown() hooks may still need database connections
// to drain state — the pools must survive the abort phase.
expect(spPool.end).not.toHaveBeenCalled();
expect(oboManager.closeAll).not.toHaveBeenCalled();
});
});

describe("LakebasePlugin — destructive mode", () => {
test("does NOT wrap in read-only transaction when readOnly: false", async () => {
const queryMock = vi.fn((_text: string, _values?: unknown[]) =>
Expand Down
Loading
Loading