History

End a request's sandbox fibers before its database connection closes (#1799)

executeWithPause forks the sandbox as a daemon so a pause can outlive the caller that observed it. That fiber closes over the executor, and the FumaDB client captures its handle at construction rather than resolving it per operation, so the fiber keeps a live reference to the connection the host opened for the scope that built the engine. On the HTTP executor plane that scope is the request, and its finalizer closes the pool as soon as the response is written. A sandbox still parked at that moment issues its next query against a closed pool. Add ExecutionEngine.shutdown, which interrupts the engine's in-flight sandbox fibers and waits for them to unwind, and run it from the shared execution-stack middleware so it happens before the request scope tears the connection down. Nothing is lost by ending it there: on a per-request engine the pause is already unreachable once the response is written, since a resume lands on a different engine and replays the call. The MCP session Durable Object does not use this middleware and keeps its session-lifetime pauses. shutdown is a required member so a decorator that rebuilds the engine object cannot drop it silently.

Rhys Sullivan committed 2 months ago

Commit 6609fa61 parent ebdfb4415 files · +202 −0Browse files

Showing the changes to apps/cloud/src (4 of 15 files). Show all

Unified · Split

4 files
  1. apps/cloud/src/api/protected.test.ts +2 −0
  2. apps/cloud/src/engine/execution-gate.ts +3 −0
  3. apps/cloud/src/engine/execution-rate-limit.node.test.ts +2 −0
  4. apps/cloud/src/engine/execution-usage.ts +3 −0
apps/cloud/src/api/protected.test.ts+2−0
@@ -20,6 +20,8 @@ const makeBaseEngine = (): ExecutionEngine => · 19 unmodified lines
2020 pausedExecutionCount: () => Effect.succeed(0),
2121 hasPausedExecutions: () => Effect.succeed(false),
2222 getDescription: Effect.succeed("desc"),
23+ // The fake forks nothing, so there is no sandbox fiber to end.
24+ shutdown: Effect.void,
2325 }) as ExecutionEngine;
2426
2527 describe("withExecutionUsageTracking", () => {
apps/cloud/src/engine/execution-gate.ts+3−0
@@ -108,6 +108,9 @@ export const withPreExecutionGate = <E extends Cause.YieldableError>( · 107 unmodified lines
108108 pausedExecutionCount: () => engine.pausedExecutionCount(),
109109 hasPausedExecutions: () => engine.hasPausedExecutions(),
110110 getDescription: engine.getDescription,
111+ // Forwarded, not re-implemented: the wrapped engine owns the sandbox fibers,
112+ // so the host's request-scope teardown has to reach through this decorator.
113+ shutdown: engine.shutdown,
111114 });
112115
113116 // ---------------------------------------------------------------------------
apps/cloud/src/engine/execution-rate-limit.node.test.ts+2−0
@@ -31,6 +31,8 @@ const engineStub: ExecutionEngine = { · 30 unmodified lines
3131 pausedExecutionCount: () => Effect.succeed(0),
3232 hasPausedExecutions: () => Effect.succeed(false),
3333 getDescription: Effect.succeed("stub"),
34+ // The stub forks nothing, so there is no sandbox fiber to end.
35+ shutdown: Effect.void,
3436 };
3537
3638 /** Counter that hands out a caller-controlled sequence of counts. */
apps/cloud/src/engine/execution-usage.ts+3−0
@@ -25,4 +25,7 @@ export const withExecutionUsageTracking = <E extends Cause.YieldableError>( · 24 unmodified lines
2525 pausedExecutionCount: () => engine.pausedExecutionCount(),
2626 hasPausedExecutions: () => engine.hasPausedExecutions(),
2727 getDescription: engine.getDescription,
28+ // Forwarded, not re-implemented: the wrapped engine owns the sandbox fibers,
29+ // so the host's request-scope teardown has to reach through this decorator.
30+ shutdown: engine.shutdown,
2831 });