Skip to content

Commit a28cc8a

Browse files
Yuruzuuclaude
andcommitted
perf(server): thread opens stay fast during concurrent transcript exports (median 4.3× faster with eight running)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 20c3dd3 commit a28cc8a

2 files changed

Lines changed: 78 additions & 0 deletions

File tree

‎apps/server/src/orchestration-v2/ThreadManagementService.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import * as TestClock from "effect/testing/TestClock";
2929

3030
import { AgentScope } from "@t3tools/shared/AgentScope";
3131

32+
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
3233
import * as ServerSettings from "../serverSettings.ts";
3334
import * as SqlitePersistence from "../persistence/Sqlite.ts";
3435
import * as CommandReceiptStore from "./CommandReceiptStore.ts";
@@ -873,3 +874,76 @@ it.effect("Force stop refuses once a newer run has started, before killing anyth
873874
expect(killed).toBe(false);
874875
}),
875876
);
877+
878+
it.effect(
879+
"admits two transcript exports at a time and frees a slot on failure or interruption",
880+
() =>
881+
Effect.gen(function* () {
882+
const started = yield* Queue.unbounded<string>();
883+
const gates = new Map(
884+
["a", "b", "c", "d", "e", "f"].map((id) => [
885+
id,
886+
Deferred.makeUnsafe<void, Orchestrator.OrchestratorV2Error>(),
887+
]),
888+
);
889+
const layerTest = ThreadManagementService.layer.pipe(
890+
Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
891+
Layer.provide(
892+
Layer.mock(Orchestrator.OrchestratorV2)({
893+
getThreadSnapshot: (threadId) =>
894+
Queue.offer(started, threadId).pipe(
895+
Effect.andThen(Deferred.await(gates.get(threadId)!)),
896+
Effect.as({
897+
schemaVersion: 1,
898+
snapshotSequence: 1,
899+
projection: {
900+
thread: { id: threadId, title: threadId },
901+
updatedAt: DateTime.makeUnsafe(0),
902+
visibleTurnItems: [],
903+
} as unknown as OrchestrationV2ThreadProjection,
904+
}),
905+
),
906+
}),
907+
),
908+
Layer.provide(ServerSettings.layerTest()),
909+
Layer.provide(layerReceiptsTest),
910+
);
911+
912+
yield* Effect.gen(function* () {
913+
const threads = yield* ThreadManagementService.ThreadManagementService;
914+
const exportThread = (id: string) =>
915+
threads.getThreadTranscript(ThreadId.make(id)).pipe(
916+
Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, {
917+
getEnvironmentId: Effect.succeed(EnvironmentId.make("environment:export")),
918+
}),
919+
Effect.forkChild({ startImmediately: true }),
920+
);
921+
const a = yield* exportThread("a");
922+
const b = yield* exportThread("b");
923+
const c = yield* exportThread("c");
924+
const d = yield* exportThread("d");
925+
const e = yield* exportThread("e");
926+
const f = yield* exportThread("f");
927+
expect(yield* Queue.takeAll(started)).toEqual(["a", "b"]);
928+
929+
yield* Fiber.interrupt(c);
930+
yield* Deferred.fail(
931+
gates.get("a")!,
932+
new Orchestrator.OrchestratorProjectionError({ threadId: ThreadId.make("a"), cause: "" }),
933+
);
934+
expect(yield* Queue.take(started)).toBe("d");
935+
yield* Fiber.interrupt(b);
936+
expect(yield* Queue.take(started)).toBe("e");
937+
yield* Deferred.succeed(gates.get("d")!, undefined);
938+
expect(yield* Queue.take(started)).toBe("f");
939+
yield* Deferred.succeed(gates.get("e")!, undefined);
940+
yield* Deferred.succeed(gates.get("f")!, undefined);
941+
942+
expect((yield* Fiber.await(a))._tag).toBe("Failure");
943+
expect((yield* Fiber.join(d)).threadId).toBe("d");
944+
expect((yield* Fiber.join(e)).threadId).toBe("e");
945+
expect((yield* Fiber.join(f)).threadId).toBe("f");
946+
expect(yield* Queue.size(started)).toBe(0);
947+
}).pipe(Effect.provide(layerTest));
948+
}),
949+
);

‎apps/server/src/orchestration-v2/ThreadManagementService.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import * as Layer from "effect/Layer";
5454
import * as Option from "effect/Option";
5555
import * as Ref from "effect/Ref";
5656
import * as Schema from "effect/Schema";
57+
import * as Semaphore from "effect/Semaphore";
5758
import * as Stream from "effect/Stream";
5859

5960
import * as CommandReceiptStore from "./CommandReceiptStore.ts";
@@ -677,6 +678,8 @@ const make = Effect.gen(function* () {
677678
ensureProjectionTranscript(threadId).pipe(
678679
Effect.andThen(orchestrator.getThreadSnapshot(threadId)),
679680
);
681+
// Full-thread exports can otherwise hold every SQLite reader and stall ordinary thread opens.
682+
const transcriptExportPermits = yield* Semaphore.make(2);
680683
const getThreadTranscript = Effect.fn("orchestrationV2.threadManagement.getThreadTranscript")(
681684
function* (threadId: ThreadId) {
682685
const { projection } = yield* getThreadSnapshot(threadId);
@@ -702,6 +705,7 @@ const make = Effect.gen(function* () {
702705
}
703706
return transcript;
704707
},
708+
transcriptExportPermits.withPermits(1),
705709
);
706710
const getThreadSnapshotWindow: ThreadManagementServiceShape["getThreadSnapshotWindow"] = (
707711
threadId,

0 commit comments

Comments
 (0)