Skip to content

Commit af6754b

Browse files
perf(server): port #13704 to V2 — pull request sync reads only threads with linked pull requests
Main stopped the per-minute pull request sync from reading the whole shell snapshot. V2's sync had the same hot path, and a heavier one: it called OrchestratorV2.getShellSnapshot(), which builds every thread's shell (run, item, provider-thread and message subqueries) including archived threads, then dropped all but the linked ones. It runs every minute and again whenever a new link needs its first snapshot. ProjectionStoreV2 gains getThreadsWithPullRequests: one SELECT over orchestration_v2_projection_threads filtered to active rows whose payload has a non-empty pullRequests array, returning only the fields the sync reads. The in-memory store mirrors it. The main-side query on projection_thread_pull_requests does not apply: V2 stores links in the thread payload and never writes that V1 table. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent ace70ca commit af6754b

6 files changed

Lines changed: 153 additions & 19 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,52 @@ for (const [name, testLayer] of [
298298
);
299299
}
300300

301+
const pullRequestLink = (number: number) => ({
302+
host: "github.com",
303+
repository: "owner/repository",
304+
number,
305+
url: `https://github.com/owner/repository/pull/${number}`,
306+
source: "manual" as const,
307+
linkedAt: DateTime.formatIso(old),
308+
snapshot: null,
309+
stack: null,
310+
});
311+
312+
for (const [name, testLayer] of [
313+
["sql", SqlLayer],
314+
["memory", layerMemory],
315+
] as const) {
316+
it.effect(`${name}: lists only active threads with pull request links, oldest first`, () =>
317+
Effect.gen(function* () {
318+
const store = yield* ProjectionStoreV2;
319+
yield* createThread("no-links");
320+
yield* createThread("empty-links", { pullRequests: [] });
321+
yield* createThread("archived-link", {
322+
archivedAt: old,
323+
pullRequests: [pullRequestLink(1)],
324+
});
325+
const settled = yield* createThread("settled-link", {
326+
settledOverride: "settled",
327+
settledAt: old,
328+
updatedAt: DateTime.subtract(now, { days: 12 }),
329+
pullRequests: [pullRequestLink(2)],
330+
});
331+
const open = yield* createThread("open-link", {
332+
pullRequests: [pullRequestLink(3), pullRequestLink(4)],
333+
});
334+
335+
const threads = yield* store.getThreadsWithPullRequests();
336+
assert.deepEqual(
337+
threads.map((thread) => [thread.id, thread.settledOverride, thread.pullRequests?.length]),
338+
[
339+
[settled, "settled", 1],
340+
[open, null, 2],
341+
],
342+
);
343+
}).pipe(Effect.provide(testLayer)),
344+
);
345+
}
346+
301347
it.effect(
302348
"reads settlement candidates and thread metadata without loading historical or archived payloads",
303349
() =>

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ export type ProjectionLimitRecoveryCandidate = Pick<
149149
| "snoozedUntil"
150150
>;
151151

152+
/** The thread fields pull request sync reads, for a thread with at least one link. */
153+
export type ProjectionThreadPullRequests = Pick<
154+
OrchestrationV2AppThread,
155+
"id" | "projectId" | "settledOverride" | "settledAt" | "pullRequests"
156+
>;
157+
152158
/** Thread activity needed by settlement, without transcript or fork history. */
153159
export type ProjectionSettlementCandidate = Pick<
154160
OrchestrationV2ThreadShell,
@@ -331,6 +337,14 @@ export interface ProjectionStoreV2Shape {
331337
ReadonlyArray<ProjectionSettlementCandidate>,
332338
ProjectionStoreV2Error
333339
>;
340+
/**
341+
* Active (not deleted, not archived) threads with at least one pull request
342+
* link, in shell snapshot order. Skips run, message and item reads.
343+
*/
344+
readonly getThreadsWithPullRequests: () => Effect.Effect<
345+
ReadonlyArray<ProjectionThreadPullRequests>,
346+
ProjectionStoreV2Error
347+
>;
334348
readonly getTurnStartContext: (
335349
threadId: ThreadId,
336350
runId: RunId,
@@ -5029,6 +5043,29 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
50295043
)
50305044
.pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })));
50315045

5046+
const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = () =>
5047+
Effect.gen(function* () {
5048+
const rows = yield* sql<PayloadRow>`
5049+
SELECT payload_json
5050+
FROM orchestration_v2_projection_threads
5051+
WHERE deleted_at IS NULL
5052+
AND json_extract(payload_json, '$.archivedAt') IS NULL
5053+
AND json_array_length(payload_json, '$.pullRequests') > 0
5054+
ORDER BY updated_at ASC, thread_id ASC
5055+
`;
5056+
return yield* Effect.forEach(rows, (row) =>
5057+
decodeThreadPayload(row.payload_json).pipe(
5058+
Effect.map((thread): ProjectionThreadPullRequests => ({
5059+
id: thread.id,
5060+
projectId: thread.projectId,
5061+
settledOverride: thread.settledOverride,
5062+
settledAt: thread.settledAt,
5063+
pullRequests: thread.pullRequests ?? [],
5064+
})),
5065+
),
5066+
);
5067+
}).pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })));
5068+
50325069
const shellThreadStateFromRow = (input: {
50335070
readonly row: ShellThreadRow;
50345071
readonly runOrdinalsByThreadId: ReadonlyMap<ThreadId, Map<RunId, number>>;
@@ -5292,6 +5329,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
52925329
getThreadShell,
52935330
getThread,
52945331
getSettlementCandidates,
5332+
getThreadsWithPullRequests,
52955333
getThreadProjection,
52965334
getTurnStartContext,
52975335
getTurnStartHistory,
@@ -5413,6 +5451,31 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
54135451
left.id.localeCompare(right.id),
54145452
);
54155453
}),
5454+
getThreadsWithPullRequests: () =>
5455+
Ref.get(replayState).pipe(
5456+
Effect.map((state) =>
5457+
[...state.projections.values()]
5458+
.map(({ thread }) => thread)
5459+
.filter(
5460+
(thread) =>
5461+
thread.deletedAt === null &&
5462+
thread.archivedAt === null &&
5463+
(thread.pullRequests ?? []).length > 0,
5464+
)
5465+
.toSorted(
5466+
(left, right) =>
5467+
DateTime.toEpochMillis(left.updatedAt) -
5468+
DateTime.toEpochMillis(right.updatedAt) || left.id.localeCompare(right.id),
5469+
)
5470+
.map((thread): ProjectionThreadPullRequests => ({
5471+
id: thread.id,
5472+
projectId: thread.projectId,
5473+
settledOverride: thread.settledOverride,
5474+
settledAt: thread.settledAt,
5475+
pullRequests: thread.pullRequests ?? [],
5476+
})),
5477+
),
5478+
),
54165479
getLimitRecoveryCandidates: (options) =>
54175480
Ref.get(replayState).pipe(
54185481
Effect.map((state) =>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ it.effect(
219219
getThreadShell: () => Effect.die("unused getThreadShell"),
220220
getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)),
221221
getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"),
222+
getThreadsWithPullRequests: () => Effect.die("unused getThreadsWithPullRequests"),
222223
getThreadProjection: () => Effect.die("control effects must not load transcript"),
223224
getTurnStartContext: () => Effect.die("unused"),
224225
getTurnStartHistory: () => Effect.die("unused"),

‎apps/server/src/orchestration/PullRequestSyncReactor.test.ts‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "@t3tools/contracts";
1717
import { assert, describe, it } from "@effect/vitest";
1818
import * as Crypto from "effect/Crypto";
19+
import * as DateTime from "effect/DateTime";
1920
import * as Deferred from "effect/Deferred";
2021
import * as Effect from "effect/Effect";
2122
import * as Layer from "effect/Layer";
@@ -26,7 +27,7 @@ import { TestClock } from "effect/testing";
2627
import { PullRequestService } from "../pullRequest/PullRequestService.ts";
2728
import { ServerActivation } from "../serverActivation.ts";
2829
import { OrchestratorV2, type OrchestratorV2Shape } from "../orchestration-v2/Orchestrator.ts";
29-
import { v2PullRequestThread } from "../orchestration-v2/testkit/pullRequestFixtures.ts";
30+
import { ProjectionStoreV2 } from "../orchestration-v2/ProjectionStore.ts";
3031
import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
3132
import * as PullRequestSyncReactor from "./PullRequestSyncReactor.ts";
3233

@@ -164,6 +165,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options:
164165
const activation = yield* Deferred.make<void>();
165166
const snapshots = yield* Ref.make(options.snapshot);
166167
const snapshotReads = yield* Queue.unbounded<void>();
168+
const shellSnapshotReads = yield* Ref.make(0);
167169
const syncCommands = yield* Ref.make<ReadonlyArray<SyncCommand>>([]);
168170
const linkCommands = yield* Ref.make<ReadonlyArray<LinkCommand>>([]);
169171
const summaryCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]);
@@ -204,16 +206,28 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options:
204206
stack,
205207
invalidate: options.invalidate ?? (() => Effect.void),
206208
}),
207-
Layer.mock(OrchestratorV2)({
208-
getShellSnapshot: () =>
209+
Layer.mock(ProjectionStoreV2)({
210+
// Mirrors the store's filter: active threads that have at least one link.
211+
getThreadsWithPullRequests: () =>
209212
Queue.offer(snapshotReads, undefined).pipe(
210213
Effect.andThen(Ref.get(snapshots)),
211-
Effect.map((snapshot) => ({
212-
schemaVersion: 2,
213-
snapshotSequence: snapshot.snapshotSequence,
214-
threads: snapshot.threads.map(v2PullRequestThread),
215-
archivedThreads: [],
216-
})),
214+
Effect.map((snapshot) =>
215+
snapshot.threads
216+
.filter((thread) => thread.archivedAt === null && thread.pullRequests.length > 0)
217+
.map((thread) => ({
218+
id: thread.id,
219+
projectId: thread.projectId,
220+
settledOverride: thread.settledOverride,
221+
settledAt: thread.settledAt === null ? null : DateTime.makeUnsafe(thread.settledAt),
222+
pullRequests: thread.pullRequests,
223+
})),
224+
),
225+
),
226+
}),
227+
Layer.mock(OrchestratorV2)({
228+
getShellSnapshot: () =>
229+
Ref.update(shellSnapshotReads, (count) => count + 1).pipe(
230+
Effect.andThen(Effect.die(new Error("pull request sync must not read the shell"))),
217231
),
218232
dispatch,
219233
streamDomainEvents: Stream.empty,
@@ -226,6 +240,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options:
226240
activation,
227241
snapshots,
228242
snapshotReads,
243+
shellSnapshotReads,
229244
syncCommands,
230245
linkCommands,
231246
summaryCalls,
@@ -386,6 +401,8 @@ describe("PullRequestSyncReactor", () => {
386401
],
387402
);
388403
assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1);
404+
// Reads only linked threads, never the full shell snapshot of every thread.
405+
assert.strictEqual(yield* Ref.get(fixture.shellSnapshotReads), 0);
389406
}).pipe(Effect.provide(fixture.layer));
390407
}),
391408
),

‎apps/server/src/orchestration/PullRequestSyncReactor.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl";
22
import {
33
CommandId,
4-
type OrchestrationV2ThreadShell,
54
type PullRequestSummary,
65
type ThreadPullRequestKey,
76
type ThreadPullRequestLink,
@@ -29,13 +28,17 @@ import * as Stream from "effect/Stream";
2928
import * as PullRequestService from "../pullRequest/PullRequestService.ts";
3029
import { forkParked } from "../serverActivation.ts";
3130
import { OrchestratorV2 } from "../orchestration-v2/Orchestrator.ts";
31+
import {
32+
ProjectionStoreV2,
33+
type ProjectionThreadPullRequests,
34+
} from "../orchestration-v2/ProjectionStore.ts";
3235

3336
const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000;
3437

3538
type SnapshotFields = Omit<ThreadPullRequestSnapshot, "syncedAt">;
3639

3740
interface LinkEntry {
38-
readonly thread: OrchestrationV2ThreadShell;
41+
readonly thread: ProjectionThreadPullRequests;
3942
readonly link: ThreadPullRequestLink;
4043
}
4144

@@ -103,15 +106,15 @@ function stacksEqual(
103106
);
104107
}
105108

106-
function isUnsettled(thread: OrchestrationV2ThreadShell): boolean {
109+
function isUnsettled(thread: ProjectionThreadPullRequests): boolean {
107110
return thread.settledOverride !== "settled" && thread.settledAt === null;
108111
}
109112

110113
/**
111114
* Keeps every thread ↔ pull request link's host snapshot current. One sweep a minute reads
112-
* the shell snapshot, groups visible links by pull request so the host is asked once per PR
113-
* no matter how many threads share it, and writes back only what changed. Native stacks the
114-
* host reports are auto-linked to the thread as `source: "stack"`.
115+
* only the active threads that have links, groups visible links by pull request so the host
116+
* is asked once per PR no matter how many threads share it, and writes back only what
117+
* changed. Native stacks the host reports are auto-linked to the thread as `source: "stack"`.
115118
*/
116119
export class PullRequestSyncReactor extends Context.Service<
117120
PullRequestSyncReactor,
@@ -126,6 +129,7 @@ export class PullRequestSyncReactor extends Context.Service<
126129
/** @public Service construction is part of the canonical Effect module API. */
127130
export const make = Effect.gen(function* () {
128131
const engine = yield* OrchestratorV2;
132+
const projections = yield* ProjectionStoreV2;
129133
const pullRequests = yield* PullRequestService.PullRequestService;
130134
const crypto = yield* Crypto.Crypto;
131135

@@ -151,14 +155,13 @@ export const make = Effect.gen(function* () {
151155
Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields);
152156

153157
const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* (requestedKey?: string) {
154-
const snapshot = yield* engine.getShellSnapshot();
158+
const threads = yield* projections.getThreadsWithPullRequests();
155159
const now = yield* DateTime.now;
156160
const nowMs = DateTime.toEpochMillis(now);
157161
const nowIso = DateTime.formatIso(now);
158162

159163
const groups = new Map<string, Array<LinkEntry>>();
160-
for (const thread of snapshot.threads) {
161-
if (thread.archivedAt !== null) continue;
164+
for (const thread of threads) {
162165
for (const link of visibleThreadPullRequests(thread.pullRequests ?? [])) {
163166
const key = threadPullRequestKeyOf(link);
164167
const entries = groups.get(key) ?? [];

‎apps/server/src/server.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,11 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll(
513513
const service = yield* PullRequestSyncReactor.PullRequestSyncReactor;
514514
yield* service.start();
515515
}),
516-
).pipe(Layer.provideMerge(PullRequestSyncReactor.layer), Layer.provide(PullRequestServiceLive)),
516+
).pipe(
517+
Layer.provideMerge(PullRequestSyncReactor.layer),
518+
Layer.provide(PullRequestServiceLive),
519+
Layer.provide(ProjectionStoreV2.layer),
520+
),
517521
// Subscribes to `account.rate-limits.updated` so usage bars track live
518522
// telemetry instead of waiting for the next status probe.
519523
ProviderUsageLimitsIngestionLive,

0 commit comments

Comments
 (0)