authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 07:19:37-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 07:19:37-07:00
log2263e13fda8c9a4f1b6f4dee32e3c9020195e2aa
treefa10fe35976c1b5f9b4384cb209e5b79dc0736b9
parent27e6cc27fe0f3cff53a44905e40615b7db99c80c
signature Signed by PGP key B5690EEEBB952194

perf(server): batch projector cursor writes (#9671)


4 files changed, 161 insertions(+), 37 deletions(-)

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+115-32
...@@ -20,6 +20,7 @@ import * as Layer from "effect/Layer";...@@ -20,6 +20,7 @@ import * as Layer from "effect/Layer";
20import * as Path from "effect/Path";20import * as Path from "effect/Path";
21import * as SqlClient from "effect/unstable/sql/SqlClient";21import * as SqlClient from "effect/unstable/sql/SqlClient";
2222
23import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts";
23import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";24import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
24import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";25import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
25import {26import {
...@@ -27,6 +28,7 @@ import {...@@ -27,6 +28,7 @@ import {
27 SqlitePersistenceMemory,28 SqlitePersistenceMemory,
28} from "../../persistence/Layers/Sqlite.ts";29} from "../../persistence/Layers/Sqlite.ts";
29import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";30import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
31import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts";
30import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";32import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
31import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";33import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
32import {34import {
...@@ -58,6 +60,54 @@ const exists = (filePath: string) =>...@@ -58,6 +60,54 @@ const exists = (filePath: string) =>
5860
59const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-");61const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-");
6062
63it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cursor-batch-")))(
64 "OrchestrationProjectionPipeline cursor batches",
65 (it) => {
66 it.effect("writes a project and all projector cursors in two statements", () =>
67 Effect.gen(function* () {
68 const projectionPipeline = yield* OrchestrationProjectionPipeline;
69 const eventStore = yield* OrchestrationEventStore;
70 const projectionState = yield* ProjectionStateRepository;
71 const counter = makeSqlStatementCounter();
72 const createdAt = "2026-01-01T00:00:00.000Z";
73 const event = yield* eventStore.append({
74 type: "project.created",
75 eventId: EventId.make("evt-cursor-batch-project"),
76 aggregateKind: "project",
77 aggregateId: ProjectId.make("project-cursor-batch"),
78 occurredAt: createdAt,
79 commandId: CommandId.make("cmd-cursor-batch-project"),
80 causationEventId: null,
81 correlationId: null,
82 metadata: {},
83 payload: {
84 projectId: ProjectId.make("project-cursor-batch"),
85 title: "Cursor batch project",
86 workspaceRoot: "/tmp/project-cursor-batch",
87 defaultModelSelection: null,
88 scripts: [],
89 createdAt,
90 updatedAt: createdAt,
91 },
92 });
93
94 yield* projectionPipeline.projectEvent(event).pipe(Effect.withTracer(counter.tracer));
95 assert.strictEqual(counter.count(), 2);
96 assert.deepEqual(
97 yield* projectionState.listAll(),
98 Object.values(ORCHESTRATION_PROJECTOR_NAMES)
99 .sort()
100 .map((projector) => ({
101 projector,
102 lastAppliedSequence: event.sequence,
103 updatedAt: createdAt,
104 })),
105 );
106 }),
107 );
108 },
109);
110
61it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {111it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
62 it.effect("bootstraps all projection states and writes projection rows", () =>112 it.effect("bootstraps all projection states and writes projection rows", () =>
63 Effect.gen(function* () {113 Effect.gen(function* () {
...@@ -813,6 +863,8 @@ it.layer(...@@ -813,6 +863,8 @@ it.layer(
813 },863 },
814 });864 });
815865
866 const projectionState = yield* ProjectionStateRepository;
867 const cursorsBeforeFailure = yield* projectionState.listAll();
816 yield* sql`868 yield* sql`
817 CREATE TRIGGER fail_thread_messages_projection_state_update869 CREATE TRIGGER fail_thread_messages_projection_state_update
818 BEFORE UPDATE ON projection_state870 BEFORE UPDATE ON projection_state
...@@ -822,39 +874,39 @@ it.layer(...@@ -822,39 +874,39 @@ it.layer(
822 END;874 END;
823 `;875 `;
824876
825 const result = yield* Effect.result(877 const pendingEvent = yield* eventStore.append({
826 appendAndProject({878 type: "thread.message-sent",
827 type: "thread.message-sent",879 eventId: EventId.make("evt-rollback-3"),
828 eventId: EventId.make("evt-rollback-3"),880 aggregateKind: "thread",
829 aggregateKind: "thread",881 aggregateId: ThreadId.make("thread-rollback"),
830 aggregateId: ThreadId.make("thread-rollback"),882 occurredAt: now,
831 occurredAt: now,883 commandId: CommandId.make("cmd-rollback-3"),
832 commandId: CommandId.make("cmd-rollback-3"),884 causationEventId: null,
833 causationEventId: null,885 correlationId: CorrelationId.make("cmd-rollback-3"),
834 correlationId: CorrelationId.make("cmd-rollback-3"),886 metadata: {},
835 metadata: {},887 payload: {
836 payload: {888 threadId: ThreadId.make("thread-rollback"),
837 threadId: ThreadId.make("thread-rollback"),889 messageId: MessageId.make("message-rollback"),
838 messageId: MessageId.make("message-rollback"),890 role: "user",
839 role: "user",891 text: "Rollback me",
840 text: "Rollback me",892 attachments: [
841 attachments: [893 {
842 {894 type: "image",
843 type: "image",895 id: "thread-rollback-att-1",
844 id: "thread-rollback-att-1",896 name: "rollback.png",
845 name: "rollback.png",897 mimeType: "image/png",
846 mimeType: "image/png",898 sizeBytes: 5,
847 sizeBytes: 5,899 },
848 },900 ],
849 ],901 turnId: null,
850 turnId: null,902 streaming: false,
851 streaming: false,903 createdAt: now,
852 createdAt: now,904 updatedAt: now,
853 updatedAt: now,905 },
854 },906 });
855 }),907 const result = yield* Effect.result(projectionPipeline.projectEvent(pendingEvent));
856 );
857 assert.equal(result._tag, "Failure");908 assert.equal(result._tag, "Failure");
909 assert.deepEqual(yield* projectionState.listAll(), cursorsBeforeFailure);
858910
859 const rows = yield* sql<{911 const rows = yield* sql<{
860 readonly count: number;912 readonly count: number;
...@@ -869,6 +921,21 @@ it.layer(...@@ -869,6 +921,21 @@ it.layer(
869 const attachmentPath = path.join(attachmentsDir, "thread-rollback-att-1.png");921 const attachmentPath = path.join(attachmentsDir, "thread-rollback-att-1.png");
870 assert.isFalse(yield* exists(attachmentPath));922 assert.isFalse(yield* exists(attachmentPath));
871 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;923 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;
924
925 yield* projectionPipeline.bootstrap;
926 yield* projectionPipeline.bootstrap;
927 assert.deepEqual(
928 yield* projectionState.listAll(),
929 cursorsBeforeFailure.map((cursor) => ({
930 ...cursor,
931 lastAppliedSequence: pendingEvent.sequence,
932 updatedAt: pendingEvent.occurredAt,
933 })),
934 );
935 const replayedMessages = yield* sql<{ readonly text: string }>`
936 SELECT text FROM projection_thread_messages WHERE message_id = 'message-rollback'
937 `;
938 assert.deepEqual(replayedMessages, [{ text: "Rollback me" }]);
872 }),939 }),
873 );940 );
874});941});
...@@ -3888,6 +3955,14 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -3888,6 +3955,14 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
38883955
3889 yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });3956 yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
3890 yield* fileSystem.writeFileString(attachmentPath, "keep this attachment");3957 yield* fileSystem.writeFileString(attachmentPath, "keep this attachment");
3958 const readCursors = sql<{
3959 readonly projector: string;
3960 readonly lastAppliedSequence: number;
3961 }>`
3962 SELECT projector, last_applied_sequence AS "lastAppliedSequence"
3963 FROM projection_state ORDER BY projector
3964 `;
3965 const cursorsBeforeFailure = yield* readCursors;
3891 yield* sql`3966 yield* sql`
3892 CREATE TRIGGER fail_attachment_command_receipt3967 CREATE TRIGGER fail_attachment_command_receipt
3893 BEFORE INSERT ON orchestration_command_receipts3968 BEFORE INSERT ON orchestration_command_receipts
...@@ -3899,6 +3974,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -3899,6 +3974,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
3899 const deleteCommand = { type: "thread.delete", commandId, threadId } as const;3974 const deleteCommand = { type: "thread.delete", commandId, threadId } as const;
3900 const dispatchError = yield* engine.dispatch(deleteCommand).pipe(Effect.flip);3975 const dispatchError = yield* engine.dispatch(deleteCommand).pipe(Effect.flip);
3901 assert.equal(dispatchError._tag, "PersistenceSqlError");3976 assert.equal(dispatchError._tag, "PersistenceSqlError");
3977 assert.deepEqual(yield* readCursors, cursorsBeforeFailure);
3902 assert.equal(yield* fileSystem.readFileString(attachmentPath), "keep this attachment");3978 assert.equal(yield* fileSystem.readFileString(attachmentPath), "keep this attachment");
3903 const rolledBackThreads = yield* sql<{ readonly deletedAt: string | null }>`3979 const rolledBackThreads = yield* sql<{ readonly deletedAt: string | null }>`
3904 SELECT deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId}3980 SELECT deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId}
...@@ -3915,6 +3991,13 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -3915,6 +3991,13 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
3915 yield* sql`DROP TRIGGER fail_attachment_command_receipt`;3991 yield* sql`DROP TRIGGER fail_attachment_command_receipt`;
39163992
3917 const result = yield* engine.dispatch(deleteCommand);3993 const result = yield* engine.dispatch(deleteCommand);
3994 assert.deepEqual(
3995 yield* readCursors,
3996 cursorsBeforeFailure.map((cursor) => ({
3997 ...cursor,
3998 lastAppliedSequence: result.sequence,
3999 })),
4000 );
3918 assert.isFalse(yield* exists(attachmentPath));4001 assert.isFalse(yield* exists(attachmentPath));
3919 const committedReceipts = yield* sql<{4002 const committedReceipts = yield* sql<{
3920 readonly status: string;4003 readonly status: string;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+15-5
...@@ -1947,11 +1947,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1947,11 +1947,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1947 prunedThreadRelativePaths: new Map<string, Set<string>>(),1947 prunedThreadRelativePaths: new Map<string, Set<string>>(),
1948 };1948 };
1949 yield* sql.withTransaction(1949 yield* sql.withTransaction(
1950 Effect.forEach(1950 Effect.gen(function* () {
1951 projectors,1951 yield* Effect.forEach(
1952 (projector) => applyProjectorForEvent(projector, event, attachmentSideEffects),1952 projectors,
1953 { concurrency: 1, discard: true },1953 (projector) => projector.apply(event, attachmentSideEffects),
1954 ),1954 { concurrency: 1, discard: true },
1955 );
1956 // Runtime projectors commit together. Bootstrap still advances each cursor separately.
1957 yield* projectionStateRepository.upsertMany(
1958 projectors.map((projector) => ({
1959 projector: projector.name,
1960 lastAppliedSequence: event.sequence,
1961 updatedAt: event.occurredAt,
1962 })),
1963 );
1964 }),
1955 );1965 );
1956 // Return the cleanup effect so the caller runs it after the outer transaction commits.1966 // Return the cleanup effect so the caller runs it after the outer transaction commits.
1957 // @effect-diagnostics-next-line returnEffectInGen:off1967 // @effect-diagnostics-next-line returnEffectInGen:off
apps/server/src/persistence/Layers/ProjectionState.ts+26
...@@ -42,6 +42,26 @@ const makeProjectionStateRepository = Effect.gen(function* () {...@@ -42,6 +42,26 @@ const makeProjectionStateRepository = Effect.gen(function* () {
42 `,42 `,
43 });43 });
4444
45 const upsertProjectionStateRows = SqlSchema.void({
46 Request: Schema.Array(ProjectionState),
47 execute: (rows) =>
48 rows.length === 0
49 ? Effect.void
50 : sql`
51 INSERT INTO projection_state ${sql.insert(
52 rows.map((row) => ({
53 projector: row.projector,
54 last_applied_sequence: row.lastAppliedSequence,
55 updated_at: row.updatedAt,
56 })),
57 )}
58 ON CONFLICT (projector)
59 DO UPDATE SET
60 last_applied_sequence = excluded.last_applied_sequence,
61 updated_at = excluded.updated_at
62 `,
63 });
64
45 const getProjectionStateRow = SqlSchema.findOneOption({65 const getProjectionStateRow = SqlSchema.findOneOption({
46 Request: GetProjectionStateInput,66 Request: GetProjectionStateInput,
47 Result: ProjectionState,67 Result: ProjectionState,
...@@ -86,6 +106,11 @@ const makeProjectionStateRepository = Effect.gen(function* () {...@@ -86,6 +106,11 @@ const makeProjectionStateRepository = Effect.gen(function* () {
86 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")),106 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")),
87 );107 );
88108
109 const upsertMany: ProjectionStateRepositoryShape["upsertMany"] = (rows) =>
110 upsertProjectionStateRows(rows).pipe(
111 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsertMany:query")),
112 );
113
89 const getByProjector: ProjectionStateRepositoryShape["getByProjector"] = (input) =>114 const getByProjector: ProjectionStateRepositoryShape["getByProjector"] = (input) =>
90 getProjectionStateRow(input).pipe(115 getProjectionStateRow(input).pipe(
91 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.getByProjector:query")),116 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.getByProjector:query")),
...@@ -106,6 +131,7 @@ const makeProjectionStateRepository = Effect.gen(function* () {...@@ -106,6 +131,7 @@ const makeProjectionStateRepository = Effect.gen(function* () {
106131
107 return {132 return {
108 upsert,133 upsert,
134 upsertMany,
109 getByProjector,135 getByProjector,
110 listAll,136 listAll,
111 minLastAppliedSequence,137 minLastAppliedSequence,
apps/server/src/persistence/Services/ProjectionState.ts+5
...@@ -37,6 +37,11 @@ export interface ProjectionStateRepositoryShape {...@@ -37,6 +37,11 @@ export interface ProjectionStateRepositoryShape {
37 */37 */
38 readonly upsert: (row: ProjectionState) => Effect.Effect<void, ProjectionRepositoryError>;38 readonly upsert: (row: ProjectionState) => Effect.Effect<void, ProjectionRepositoryError>;
3939
40 /** Insert or replace projector cursors in one statement. Empty batches do nothing. */
41 readonly upsertMany: (
42 rows: ReadonlyArray<ProjectionState>,
43 ) => Effect.Effect<void, ProjectionRepositoryError>;
44
40 /**45 /**
41 * Read projection cursor state for a projector key.46 * Read projection cursor state for a projector key.
42 */47 */