Commit 29677d1

Browse files
committed
Fix thread subscription event loss and snapshot sequence race
- Attach the domain-event PubSub subscription before reading the catch-up replay or snapshot baseline in subscribeThread, so events published while the baseline loads are buffered instead of dropped (new OrchestrationEngine.subscribeDomainEvents, scoped to the RPC stream's lifetime via observeRpcStreamEffect). - Make the afterSequence catch-up replay exhaustive instead of truncating at the event store's default 1,000-event cap, which could silently drop thread events under multi-thread activity. - Read the thread detail and projection snapshot sequence inside one transaction (ProjectionSnapshotQuery.getThreadDetailSnapshot), shared by the HTTP threadSnapshot endpoint and the WS snapshot path, so the reported sequence can never run ahead of the embedded thread.
1 parent fad44e1 commit 29677d1

13 files changed

Lines changed: 117 additions & 47 deletions

‎apps/server/src/checkpointing/CheckpointDiffQuery.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => {
107107
}),
108108
getThreadShellById: ()=>Effect.succeed(Option.none()),
109109
getThreadDetailById: ()=>Effect.succeed(Option.none()),
110+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
110111
}),
111112
),
112113
);
@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
199200
getFullThreadDiffContext: ()=>Effect.die("unused"),
200201
getThreadShellById: ()=>Effect.succeed(Option.none()),
201202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
202204
}),
203205
),
204206
);
@@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {
281283
getFullThreadDiffContext: ()=>Effect.die("unused"),
282284
getThreadShellById: ()=>Effect.succeed(Option.none()),
283285
getThreadDetailById: ()=>Effect.succeed(Option.none()),
286+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
284287
}),
285288
),
286289
);
@@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => {
348351
getFullThreadDiffContext: ()=>Effect.die("unused"),
349352
getThreadShellById: ()=>Effect.succeed(Option.none()),
350353
getThreadDetailById: ()=>Effect.succeed(Option.none()),
354+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
351355
}),
352356
),
353357
);
@@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => {
400404
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
401405
getThreadShellById: ()=>Effect.succeed(Option.none()),
402406
getThreadDetailById: ()=>Effect.succeed(Option.none()),
407+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
403408
}),
404409
),
405410
);

‎apps/server/src/observability/RpcInstrumentation.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55
import*asExitfrom"effect/Exit";
66
import*asMetricfrom"effect/Metric";
77
import*asReferencesfrom"effect/References";
8+
importtype*asScopefrom"effect/Scope";
89
import*asStreamfrom"effect/Stream";
910

1011
import{outcomeFromExit}from"./Attributes.ts";
@@ -123,7 +124,14 @@ export const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectErro
123124
method: string,
124125
effect: Effect.Effect<Stream.Stream<A,StreamError,StreamContext>,EffectError,EffectContext>,
125126
traceAttributes?: Readonly<Record<string,unknown>>,
126-
): Stream.Stream<A,StreamError|EffectError,StreamContext|EffectContext>=>{
127+
// `Stream.unwrap` scopes the setup effect to the stream's lifetime, so a
128+
// `Scope` requirement (e.g. PubSub subscriptions attached before a snapshot
129+
// is loaded) is satisfied by the stream itself rather than the caller.
130+
): Stream.Stream<
131+
A,
132+
StreamError|EffectError,
133+
StreamContext|Exclude<EffectContext,Scope.Scope>
134+
>=>{
127135
constinstrumented=Stream.unwrap(
128136
Effect.gen(function*(){
129137
conststartedAt=yield*Clock.currentTimeNanos;

‎apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => {
200200
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
201201
getThreadShellById: ()=>Effect.succeed(Option.none()),
202202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
203204
}),
204205
),
205206
Layer.provide(

‎apps/server/src/orchestration/Layers/OrchestrationEngine.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
306306
Effect.annotateLogs({sequence: commandReadModel.snapshotSequence}),
307307
);
308308

309-
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive)=>
310-
eventStore.readFromSequence(fromSequenceExclusive);
309+
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive,limit)=>
310+
eventStore.readFromSequence(fromSequenceExclusive,limit);
311311

312312
constdispatch: OrchestrationEngineShape["dispatch"]=(command)=>
313313
Effect.gen(function*(){
@@ -329,6 +329,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
329329
getstreamDomainEvents(): OrchestrationEngineShape["streamDomainEvents"]{
330330
returnStream.fromPubSub(eventPubSub);
331331
},
332+
subscribeDomainEvents: Effect.map(PubSub.subscribe(eventPubSub),Stream.fromSubscription),
332333
}satisfiesOrchestrationEngineShape;
333334
});
334335

‎apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20332033
);
20342034
});
20352035

2036+
constgetThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"]=(
2037+
threadId,
2038+
)=>
2039+
sql.withTransaction(Effect.all([getThreadDetailById(threadId),getSnapshotSequence()])).pipe(
2040+
Effect.map(([threadDetail,{ snapshotSequence }])=>
2041+
Option.map(threadDetail,(thread)=>({ snapshotSequence, thread })),
2042+
),
2043+
Effect.mapError((error)=>{
2044+
if(isPersistenceError(error)){
2045+
returnerror;
2046+
}
2047+
returntoPersistenceSqlError("ProjectionSnapshotQuery.getThreadDetailSnapshot:query")(
2048+
error,
2049+
);
2050+
}),
2051+
);
2052+
20362053
return{
20372054
getCommandReadModel,
20382055
getSnapshot,
@@ -2047,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20472064
getFullThreadDiffContext,
20482065
getThreadShellById,
20492066
getThreadDetailById,
2067+
getThreadDetailSnapshot,
20502068
}satisfiesProjectionSnapshotQueryShape;
20512069
});
20522070

‎apps/server/src/orchestration/Services/OrchestrationEngine.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
importtype{OrchestrationCommand,OrchestrationEvent}from"@t3tools/contracts";
1414
import*asContextfrom"effect/Context";
1515
importtype*asEffectfrom"effect/Effect";
16+
importtype*asScopefrom"effect/Scope";
1617
importtype*asStreamfrom"effect/Stream";
1718

1819
importtype{OrchestrationDispatchError}from"../Errors.ts";
@@ -26,10 +27,14 @@ export interface OrchestrationEngineShape {
2627
* Replay persisted orchestration events from an exclusive sequence cursor.
2728
*
2829
* @param fromSequenceExclusive - Sequence cursor (exclusive).
30+
* @param limit - Optional maximum number of events to replay. Defaults to
31+
* the event store's replay cap; pass `Number.MAX_SAFE_INTEGER` for an
32+
* exhaustive catch-up replay.
2933
* @returns Stream containing ordered events.
3034
*/
3135
readonlyreadEvents: (
3236
fromSequenceExclusive: number,
37+
limit?: number,
3338
)=>Stream.Stream<OrchestrationEvent,OrchestrationEventStoreError,never>;
3439

3540
/**
@@ -49,8 +54,26 @@ export interface OrchestrationEngineShape {
4954
* Stream persisted domain events in dispatch order.
5055
*
5156
* This is a hot runtime stream (new events only), not a historical replay.
57+
* The underlying PubSub subscription is only attached once the stream is
58+
* pulled; use `subscribeDomainEvents` when the subscription must be
59+
* attached before other work (e.g. loading a snapshot baseline).
5260
*/
5361
readonlystreamDomainEvents: Stream.Stream<OrchestrationEvent>;
62+
63+
/**
64+
* Attach a domain event subscription immediately and return the stream of
65+
* events it receives.
66+
*
67+
* Unlike `streamDomainEvents`, events published between running this effect
68+
* and pulling the returned stream are buffered by the subscription instead
69+
* of dropped, which is required for snapshot/catch-up + live combinations.
70+
* The subscription is released when the surrounding scope closes.
71+
*/
72+
readonlysubscribeDomainEvents: Effect.Effect<
73+
Stream.Stream<OrchestrationEvent>,
74+
never,
75+
Scope.Scope
76+
>;
5477
}
5578

5679
/**

‎apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
OrchestrationReadModel,
1515
OrchestrationShellSnapshot,
1616
OrchestrationThread,
17+
OrchestrationThreadDetailSnapshot,
1718
OrchestrationThreadShell,
1819
ProjectId,
1920
ThreadId,
@@ -157,6 +158,15 @@ export interface ProjectionSnapshotQueryShape {
157158
readonlygetThreadDetailById: (
158159
threadId: ThreadId,
159160
)=>Effect.Effect<Option.Option<OrchestrationThread>,ProjectionRepositoryError>;
161+
162+
/**
163+
* Read a single active thread detail together with the projection snapshot
164+
* sequence, both observed inside one transaction so the sequence never runs
165+
* ahead of the thread rows it is paired with.
166+
*/
167+
readonlygetThreadDetailSnapshot: (
168+
threadId: ThreadId,
169+
)=>Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>,ProjectionRepositoryError>;
160170
}
161171

162172
/**

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
4545
Effect.fn("environment.orchestration.threadSnapshot")(function*(args){
4646
yield*annotateEnvironmentRequest(args.endpoint.name);
4747
yield*requireEnvironmentScope(AuthOrchestrationReadScope);
48-
const[threadDetail,{ snapshotSequence }]=yield*Effect.all([
49-
projectionSnapshotQuery.getThreadDetailById(args.params.threadId),
50-
projectionSnapshotQuery.getSnapshotSequence(),
51-
]).pipe(
52-
Effect.catch((cause)=>
53-
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
54-
),
55-
);
56-
if(Option.isNone(threadDetail)){
48+
constsnapshot=yield*projectionSnapshotQuery
49+
.getThreadDetailSnapshot(args.params.threadId)
50+
.pipe(
51+
Effect.catch((cause)=>
52+
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
53+
),
54+
);
55+
if(Option.isNone(snapshot)){
5756
returnyield*failEnvironmentNotFound("thread_not_found");
5857
}
59-
return{ snapshotSequence,thread: threadDetail.value};
58+
returnsnapshot.value;
6059
}),
6160
)
6261
.handle(

‎apps/server/src/project/ProjectSetupScriptRunner.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
4343
getFullThreadDiffContext: ()=>Effect.die("unused"),
4444
getThreadShellById: ()=>Effect.die("unused"),
4545
getThreadDetailById: ()=>Effect.die("unused"),
46+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
4647
});
4748

4849
constmakeTerminalManagerLayer=(

‎apps/server/src/provider/Layers/ProviderSessionReaper.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe("ProviderSessionReaper", () => {
209209
: Option.none(),
210210
),
211211
getThreadDetailById: ()=>Effect.die("unused"),
212+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
212213
}),
213214
),
214215
Layer.provideMerge(NodeServices.layer),

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Commit 29677d1

Browse files
committed
Fix thread subscription event loss and snapshot sequence race
- Attach the domain-event PubSub subscription before reading the catch-up replay or snapshot baseline in subscribeThread, so events published while the baseline loads are buffered instead of dropped (new OrchestrationEngine.subscribeDomainEvents, scoped to the RPC stream's lifetime via observeRpcStreamEffect). - Make the afterSequence catch-up replay exhaustive instead of truncating at the event store's default 1,000-event cap, which could silently drop thread events under multi-thread activity. - Read the thread detail and projection snapshot sequence inside one transaction (ProjectionSnapshotQuery.getThreadDetailSnapshot), shared by the HTTP threadSnapshot endpoint and the WS snapshot path, so the reported sequence can never run ahead of the embedded thread.
1 parent fad44e1 commit 29677d1

13 files changed

Lines changed: 117 additions & 47 deletions

‎apps/server/src/checkpointing/CheckpointDiffQuery.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => {
107107
}),
108108
getThreadShellById: ()=>Effect.succeed(Option.none()),
109109
getThreadDetailById: ()=>Effect.succeed(Option.none()),
110+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
110111
}),
111112
),
112113
);
@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
199200
getFullThreadDiffContext: ()=>Effect.die("unused"),
200201
getThreadShellById: ()=>Effect.succeed(Option.none()),
201202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
202204
}),
203205
),
204206
);
@@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {
281283
getFullThreadDiffContext: ()=>Effect.die("unused"),
282284
getThreadShellById: ()=>Effect.succeed(Option.none()),
283285
getThreadDetailById: ()=>Effect.succeed(Option.none()),
286+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
284287
}),
285288
),
286289
);
@@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => {
348351
getFullThreadDiffContext: ()=>Effect.die("unused"),
349352
getThreadShellById: ()=>Effect.succeed(Option.none()),
350353
getThreadDetailById: ()=>Effect.succeed(Option.none()),
354+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
351355
}),
352356
),
353357
);
@@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => {
400404
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
401405
getThreadShellById: ()=>Effect.succeed(Option.none()),
402406
getThreadDetailById: ()=>Effect.succeed(Option.none()),
407+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
403408
}),
404409
),
405410
);

‎apps/server/src/observability/RpcInstrumentation.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55
import*asExitfrom"effect/Exit";
66
import*asMetricfrom"effect/Metric";
77
import*asReferencesfrom"effect/References";
8+
importtype*asScopefrom"effect/Scope";
89
import*asStreamfrom"effect/Stream";
910

1011
import{outcomeFromExit}from"./Attributes.ts";
@@ -123,7 +124,14 @@ export const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectErro
123124
method: string,
124125
effect: Effect.Effect<Stream.Stream<A,StreamError,StreamContext>,EffectError,EffectContext>,
125126
traceAttributes?: Readonly<Record<string,unknown>>,
126-
): Stream.Stream<A,StreamError|EffectError,StreamContext|EffectContext>=>{
127+
// `Stream.unwrap` scopes the setup effect to the stream's lifetime, so a
128+
// `Scope` requirement (e.g. PubSub subscriptions attached before a snapshot
129+
// is loaded) is satisfied by the stream itself rather than the caller.
130+
): Stream.Stream<
131+
A,
132+
StreamError|EffectError,
133+
StreamContext|Exclude<EffectContext,Scope.Scope>
134+
>=>{
127135
constinstrumented=Stream.unwrap(
128136
Effect.gen(function*(){
129137
conststartedAt=yield*Clock.currentTimeNanos;

‎apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => {
200200
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
201201
getThreadShellById: ()=>Effect.succeed(Option.none()),
202202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
203204
}),
204205
),
205206
Layer.provide(

‎apps/server/src/orchestration/Layers/OrchestrationEngine.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
306306
Effect.annotateLogs({sequence: commandReadModel.snapshotSequence}),
307307
);
308308

309-
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive)=>
310-
eventStore.readFromSequence(fromSequenceExclusive);
309+
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive,limit)=>
310+
eventStore.readFromSequence(fromSequenceExclusive,limit);
311311

312312
constdispatch: OrchestrationEngineShape["dispatch"]=(command)=>
313313
Effect.gen(function*(){
@@ -329,6 +329,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
329329
getstreamDomainEvents(): OrchestrationEngineShape["streamDomainEvents"]{
330330
returnStream.fromPubSub(eventPubSub);
331331
},
332+
subscribeDomainEvents: Effect.map(PubSub.subscribe(eventPubSub),Stream.fromSubscription),
332333
}satisfiesOrchestrationEngineShape;
333334
});
334335

‎apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20332033
);
20342034
});
20352035

2036+
constgetThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"]=(
2037+
threadId,
2038+
)=>
2039+
sql.withTransaction(Effect.all([getThreadDetailById(threadId),getSnapshotSequence()])).pipe(
2040+
Effect.map(([threadDetail,{ snapshotSequence }])=>
2041+
Option.map(threadDetail,(thread)=>({ snapshotSequence, thread })),
2042+
),
2043+
Effect.mapError((error)=>{
2044+
if(isPersistenceError(error)){
2045+
returnerror;
2046+
}
2047+
returntoPersistenceSqlError("ProjectionSnapshotQuery.getThreadDetailSnapshot:query")(
2048+
error,
2049+
);
2050+
}),
2051+
);
2052+
20362053
return{
20372054
getCommandReadModel,
20382055
getSnapshot,
@@ -2047,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20472064
getFullThreadDiffContext,
20482065
getThreadShellById,
20492066
getThreadDetailById,
2067+
getThreadDetailSnapshot,
20502068
}satisfiesProjectionSnapshotQueryShape;
20512069
});
20522070

‎apps/server/src/orchestration/Services/OrchestrationEngine.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
importtype{OrchestrationCommand,OrchestrationEvent}from"@t3tools/contracts";
1414
import*asContextfrom"effect/Context";
1515
importtype*asEffectfrom"effect/Effect";
16+
importtype*asScopefrom"effect/Scope";
1617
importtype*asStreamfrom"effect/Stream";
1718

1819
importtype{OrchestrationDispatchError}from"../Errors.ts";
@@ -26,10 +27,14 @@ export interface OrchestrationEngineShape {
2627
* Replay persisted orchestration events from an exclusive sequence cursor.
2728
*
2829
* @param fromSequenceExclusive - Sequence cursor (exclusive).
30+
* @param limit - Optional maximum number of events to replay. Defaults to
31+
* the event store's replay cap; pass `Number.MAX_SAFE_INTEGER` for an
32+
* exhaustive catch-up replay.
2933
* @returns Stream containing ordered events.
3034
*/
3135
readonlyreadEvents: (
3236
fromSequenceExclusive: number,
37+
limit?: number,
3338
)=>Stream.Stream<OrchestrationEvent,OrchestrationEventStoreError,never>;
3439

3540
/**
@@ -49,8 +54,26 @@ export interface OrchestrationEngineShape {
4954
* Stream persisted domain events in dispatch order.
5055
*
5156
* This is a hot runtime stream (new events only), not a historical replay.
57+
* The underlying PubSub subscription is only attached once the stream is
58+
* pulled; use `subscribeDomainEvents` when the subscription must be
59+
* attached before other work (e.g. loading a snapshot baseline).
5260
*/
5361
readonlystreamDomainEvents: Stream.Stream<OrchestrationEvent>;
62+
63+
/**
64+
* Attach a domain event subscription immediately and return the stream of
65+
* events it receives.
66+
*
67+
* Unlike `streamDomainEvents`, events published between running this effect
68+
* and pulling the returned stream are buffered by the subscription instead
69+
* of dropped, which is required for snapshot/catch-up + live combinations.
70+
* The subscription is released when the surrounding scope closes.
71+
*/
72+
readonlysubscribeDomainEvents: Effect.Effect<
73+
Stream.Stream<OrchestrationEvent>,
74+
never,
75+
Scope.Scope
76+
>;
5477
}
5578

5679
/**

‎apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
OrchestrationReadModel,
1515
OrchestrationShellSnapshot,
1616
OrchestrationThread,
17+
OrchestrationThreadDetailSnapshot,
1718
OrchestrationThreadShell,
1819
ProjectId,
1920
ThreadId,
@@ -157,6 +158,15 @@ export interface ProjectionSnapshotQueryShape {
157158
readonlygetThreadDetailById: (
158159
threadId: ThreadId,
159160
)=>Effect.Effect<Option.Option<OrchestrationThread>,ProjectionRepositoryError>;
161+
162+
/**
163+
* Read a single active thread detail together with the projection snapshot
164+
* sequence, both observed inside one transaction so the sequence never runs
165+
* ahead of the thread rows it is paired with.
166+
*/
167+
readonlygetThreadDetailSnapshot: (
168+
threadId: ThreadId,
169+
)=>Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>,ProjectionRepositoryError>;
160170
}
161171

162172
/**

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
4545
Effect.fn("environment.orchestration.threadSnapshot")(function*(args){
4646
yield*annotateEnvironmentRequest(args.endpoint.name);
4747
yield*requireEnvironmentScope(AuthOrchestrationReadScope);
48-
const[threadDetail,{ snapshotSequence }]=yield*Effect.all([
49-
projectionSnapshotQuery.getThreadDetailById(args.params.threadId),
50-
projectionSnapshotQuery.getSnapshotSequence(),
51-
]).pipe(
52-
Effect.catch((cause)=>
53-
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
54-
),
55-
);
56-
if(Option.isNone(threadDetail)){
48+
constsnapshot=yield*projectionSnapshotQuery
49+
.getThreadDetailSnapshot(args.params.threadId)
50+
.pipe(
51+
Effect.catch((cause)=>
52+
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
53+
),
54+
);
55+
if(Option.isNone(snapshot)){
5756
returnyield*failEnvironmentNotFound("thread_not_found");
5857
}
59-
return{ snapshotSequence,thread: threadDetail.value};
58+
returnsnapshot.value;
6059
}),
6160
)
6261
.handle(

‎apps/server/src/project/ProjectSetupScriptRunner.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
4343
getFullThreadDiffContext: ()=>Effect.die("unused"),
4444
getThreadShellById: ()=>Effect.die("unused"),
4545
getThreadDetailById: ()=>Effect.die("unused"),
46+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
4647
});
4748

4849
constmakeTerminalManagerLayer=(

‎apps/server/src/provider/Layers/ProviderSessionReaper.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe("ProviderSessionReaper", () => {
209209
: Option.none(),
210210
),
211211
getThreadDetailById: ()=>Effect.die("unused"),
212+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
212213
}),
213214
),
214215
Layer.provideMerge(NodeServices.layer),

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 29677d1

Browse files
committed
Fix thread subscription event loss and snapshot sequence race
- Attach the domain-event PubSub subscription before reading the catch-up replay or snapshot baseline in subscribeThread, so events published while the baseline loads are buffered instead of dropped (new OrchestrationEngine.subscribeDomainEvents, scoped to the RPC stream's lifetime via observeRpcStreamEffect). - Make the afterSequence catch-up replay exhaustive instead of truncating at the event store's default 1,000-event cap, which could silently drop thread events under multi-thread activity. - Read the thread detail and projection snapshot sequence inside one transaction (ProjectionSnapshotQuery.getThreadDetailSnapshot), shared by the HTTP threadSnapshot endpoint and the WS snapshot path, so the reported sequence can never run ahead of the embedded thread.
1 parent fad44e1 commit 29677d1

13 files changed

Lines changed: 117 additions & 47 deletions

‎apps/server/src/checkpointing/CheckpointDiffQuery.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => {
107107
}),
108108
getThreadShellById: ()=>Effect.succeed(Option.none()),
109109
getThreadDetailById: ()=>Effect.succeed(Option.none()),
110+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
110111
}),
111112
),
112113
);
@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
199200
getFullThreadDiffContext: ()=>Effect.die("unused"),
200201
getThreadShellById: ()=>Effect.succeed(Option.none()),
201202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
202204
}),
203205
),
204206
);
@@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {
281283
getFullThreadDiffContext: ()=>Effect.die("unused"),
282284
getThreadShellById: ()=>Effect.succeed(Option.none()),
283285
getThreadDetailById: ()=>Effect.succeed(Option.none()),
286+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
284287
}),
285288
),
286289
);
@@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => {
348351
getFullThreadDiffContext: ()=>Effect.die("unused"),
349352
getThreadShellById: ()=>Effect.succeed(Option.none()),
350353
getThreadDetailById: ()=>Effect.succeed(Option.none()),
354+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
351355
}),
352356
),
353357
);
@@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => {
400404
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
401405
getThreadShellById: ()=>Effect.succeed(Option.none()),
402406
getThreadDetailById: ()=>Effect.succeed(Option.none()),
407+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
403408
}),
404409
),
405410
);

‎apps/server/src/observability/RpcInstrumentation.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55
import*asExitfrom"effect/Exit";
66
import*asMetricfrom"effect/Metric";
77
import*asReferencesfrom"effect/References";
8+
importtype*asScopefrom"effect/Scope";
89
import*asStreamfrom"effect/Stream";
910

1011
import{outcomeFromExit}from"./Attributes.ts";
@@ -123,7 +124,14 @@ export const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectErro
123124
method: string,
124125
effect: Effect.Effect<Stream.Stream<A,StreamError,StreamContext>,EffectError,EffectContext>,
125126
traceAttributes?: Readonly<Record<string,unknown>>,
126-
): Stream.Stream<A,StreamError|EffectError,StreamContext|EffectContext>=>{
127+
// `Stream.unwrap` scopes the setup effect to the stream's lifetime, so a
128+
// `Scope` requirement (e.g. PubSub subscriptions attached before a snapshot
129+
// is loaded) is satisfied by the stream itself rather than the caller.
130+
): Stream.Stream<
131+
A,
132+
StreamError|EffectError,
133+
StreamContext|Exclude<EffectContext,Scope.Scope>
134+
>=>{
127135
constinstrumented=Stream.unwrap(
128136
Effect.gen(function*(){
129137
conststartedAt=yield*Clock.currentTimeNanos;

‎apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => {
200200
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
201201
getThreadShellById: ()=>Effect.succeed(Option.none()),
202202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
203204
}),
204205
),
205206
Layer.provide(

‎apps/server/src/orchestration/Layers/OrchestrationEngine.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
306306
Effect.annotateLogs({sequence: commandReadModel.snapshotSequence}),
307307
);
308308

309-
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive)=>
310-
eventStore.readFromSequence(fromSequenceExclusive);
309+
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive,limit)=>
310+
eventStore.readFromSequence(fromSequenceExclusive,limit);
311311

312312
constdispatch: OrchestrationEngineShape["dispatch"]=(command)=>
313313
Effect.gen(function*(){
@@ -329,6 +329,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
329329
getstreamDomainEvents(): OrchestrationEngineShape["streamDomainEvents"]{
330330
returnStream.fromPubSub(eventPubSub);
331331
},
332+
subscribeDomainEvents: Effect.map(PubSub.subscribe(eventPubSub),Stream.fromSubscription),
332333
}satisfiesOrchestrationEngineShape;
333334
});
334335

‎apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20332033
);
20342034
});
20352035

2036+
constgetThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"]=(
2037+
threadId,
2038+
)=>
2039+
sql.withTransaction(Effect.all([getThreadDetailById(threadId),getSnapshotSequence()])).pipe(
2040+
Effect.map(([threadDetail,{ snapshotSequence }])=>
2041+
Option.map(threadDetail,(thread)=>({ snapshotSequence, thread })),
2042+
),
2043+
Effect.mapError((error)=>{
2044+
if(isPersistenceError(error)){
2045+
returnerror;
2046+
}
2047+
returntoPersistenceSqlError("ProjectionSnapshotQuery.getThreadDetailSnapshot:query")(
2048+
error,
2049+
);
2050+
}),
2051+
);
2052+
20362053
return{
20372054
getCommandReadModel,
20382055
getSnapshot,
@@ -2047,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20472064
getFullThreadDiffContext,
20482065
getThreadShellById,
20492066
getThreadDetailById,
2067+
getThreadDetailSnapshot,
20502068
}satisfiesProjectionSnapshotQueryShape;
20512069
});
20522070

‎apps/server/src/orchestration/Services/OrchestrationEngine.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
importtype{OrchestrationCommand,OrchestrationEvent}from"@t3tools/contracts";
1414
import*asContextfrom"effect/Context";
1515
importtype*asEffectfrom"effect/Effect";
16+
importtype*asScopefrom"effect/Scope";
1617
importtype*asStreamfrom"effect/Stream";
1718

1819
importtype{OrchestrationDispatchError}from"../Errors.ts";
@@ -26,10 +27,14 @@ export interface OrchestrationEngineShape {
2627
* Replay persisted orchestration events from an exclusive sequence cursor.
2728
*
2829
* @param fromSequenceExclusive - Sequence cursor (exclusive).
30+
* @param limit - Optional maximum number of events to replay. Defaults to
31+
* the event store's replay cap; pass `Number.MAX_SAFE_INTEGER` for an
32+
* exhaustive catch-up replay.
2933
* @returns Stream containing ordered events.
3034
*/
3135
readonlyreadEvents: (
3236
fromSequenceExclusive: number,
37+
limit?: number,
3338
)=>Stream.Stream<OrchestrationEvent,OrchestrationEventStoreError,never>;
3439

3540
/**
@@ -49,8 +54,26 @@ export interface OrchestrationEngineShape {
4954
* Stream persisted domain events in dispatch order.
5055
*
5156
* This is a hot runtime stream (new events only), not a historical replay.
57+
* The underlying PubSub subscription is only attached once the stream is
58+
* pulled; use `subscribeDomainEvents` when the subscription must be
59+
* attached before other work (e.g. loading a snapshot baseline).
5260
*/
5361
readonlystreamDomainEvents: Stream.Stream<OrchestrationEvent>;
62+
63+
/**
64+
* Attach a domain event subscription immediately and return the stream of
65+
* events it receives.
66+
*
67+
* Unlike `streamDomainEvents`, events published between running this effect
68+
* and pulling the returned stream are buffered by the subscription instead
69+
* of dropped, which is required for snapshot/catch-up + live combinations.
70+
* The subscription is released when the surrounding scope closes.
71+
*/
72+
readonlysubscribeDomainEvents: Effect.Effect<
73+
Stream.Stream<OrchestrationEvent>,
74+
never,
75+
Scope.Scope
76+
>;
5477
}
5578

5679
/**

‎apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
OrchestrationReadModel,
1515
OrchestrationShellSnapshot,
1616
OrchestrationThread,
17+
OrchestrationThreadDetailSnapshot,
1718
OrchestrationThreadShell,
1819
ProjectId,
1920
ThreadId,
@@ -157,6 +158,15 @@ export interface ProjectionSnapshotQueryShape {
157158
readonlygetThreadDetailById: (
158159
threadId: ThreadId,
159160
)=>Effect.Effect<Option.Option<OrchestrationThread>,ProjectionRepositoryError>;
161+
162+
/**
163+
* Read a single active thread detail together with the projection snapshot
164+
* sequence, both observed inside one transaction so the sequence never runs
165+
* ahead of the thread rows it is paired with.
166+
*/
167+
readonlygetThreadDetailSnapshot: (
168+
threadId: ThreadId,
169+
)=>Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>,ProjectionRepositoryError>;
160170
}
161171

162172
/**

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
4545
Effect.fn("environment.orchestration.threadSnapshot")(function*(args){
4646
yield*annotateEnvironmentRequest(args.endpoint.name);
4747
yield*requireEnvironmentScope(AuthOrchestrationReadScope);
48-
const[threadDetail,{ snapshotSequence }]=yield*Effect.all([
49-
projectionSnapshotQuery.getThreadDetailById(args.params.threadId),
50-
projectionSnapshotQuery.getSnapshotSequence(),
51-
]).pipe(
52-
Effect.catch((cause)=>
53-
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
54-
),
55-
);
56-
if(Option.isNone(threadDetail)){
48+
constsnapshot=yield*projectionSnapshotQuery
49+
.getThreadDetailSnapshot(args.params.threadId)
50+
.pipe(
51+
Effect.catch((cause)=>
52+
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
53+
),
54+
);
55+
if(Option.isNone(snapshot)){
5756
returnyield*failEnvironmentNotFound("thread_not_found");
5857
}
59-
return{ snapshotSequence,thread: threadDetail.value};
58+
returnsnapshot.value;
6059
}),
6160
)
6261
.handle(

‎apps/server/src/project/ProjectSetupScriptRunner.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
4343
getFullThreadDiffContext: ()=>Effect.die("unused"),
4444
getThreadShellById: ()=>Effect.die("unused"),
4545
getThreadDetailById: ()=>Effect.die("unused"),
46+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
4647
});
4748

4849
constmakeTerminalManagerLayer=(

‎apps/server/src/provider/Layers/ProviderSessionReaper.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe("ProviderSessionReaper", () => {
209209
: Option.none(),
210210
),
211211
getThreadDetailById: ()=>Effect.die("unused"),
212+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
212213
}),
213214
),
214215
Layer.provideMerge(NodeServices.layer),

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 29677d1

Browse files
committed
Fix thread subscription event loss and snapshot sequence race
- Attach the domain-event PubSub subscription before reading the catch-up replay or snapshot baseline in subscribeThread, so events published while the baseline loads are buffered instead of dropped (new OrchestrationEngine.subscribeDomainEvents, scoped to the RPC stream's lifetime via observeRpcStreamEffect). - Make the afterSequence catch-up replay exhaustive instead of truncating at the event store's default 1,000-event cap, which could silently drop thread events under multi-thread activity. - Read the thread detail and projection snapshot sequence inside one transaction (ProjectionSnapshotQuery.getThreadDetailSnapshot), shared by the HTTP threadSnapshot endpoint and the WS snapshot path, so the reported sequence can never run ahead of the embedded thread.
1 parent fad44e1 commit 29677d1

13 files changed

Lines changed: 117 additions & 47 deletions

‎apps/server/src/checkpointing/CheckpointDiffQuery.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => {
107107
}),
108108
getThreadShellById: ()=>Effect.succeed(Option.none()),
109109
getThreadDetailById: ()=>Effect.succeed(Option.none()),
110+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
110111
}),
111112
),
112113
);
@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
199200
getFullThreadDiffContext: ()=>Effect.die("unused"),
200201
getThreadShellById: ()=>Effect.succeed(Option.none()),
201202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
202204
}),
203205
),
204206
);
@@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {
281283
getFullThreadDiffContext: ()=>Effect.die("unused"),
282284
getThreadShellById: ()=>Effect.succeed(Option.none()),
283285
getThreadDetailById: ()=>Effect.succeed(Option.none()),
286+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
284287
}),
285288
),
286289
);
@@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => {
348351
getFullThreadDiffContext: ()=>Effect.die("unused"),
349352
getThreadShellById: ()=>Effect.succeed(Option.none()),
350353
getThreadDetailById: ()=>Effect.succeed(Option.none()),
354+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
351355
}),
352356
),
353357
);
@@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => {
400404
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
401405
getThreadShellById: ()=>Effect.succeed(Option.none()),
402406
getThreadDetailById: ()=>Effect.succeed(Option.none()),
407+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
403408
}),
404409
),
405410
);

‎apps/server/src/observability/RpcInstrumentation.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55
import*asExitfrom"effect/Exit";
66
import*asMetricfrom"effect/Metric";
77
import*asReferencesfrom"effect/References";
8+
importtype*asScopefrom"effect/Scope";
89
import*asStreamfrom"effect/Stream";
910

1011
import{outcomeFromExit}from"./Attributes.ts";
@@ -123,7 +124,14 @@ export const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectErro
123124
method: string,
124125
effect: Effect.Effect<Stream.Stream<A,StreamError,StreamContext>,EffectError,EffectContext>,
125126
traceAttributes?: Readonly<Record<string,unknown>>,
126-
): Stream.Stream<A,StreamError|EffectError,StreamContext|EffectContext>=>{
127+
// `Stream.unwrap` scopes the setup effect to the stream's lifetime, so a
128+
// `Scope` requirement (e.g. PubSub subscriptions attached before a snapshot
129+
// is loaded) is satisfied by the stream itself rather than the caller.
130+
): Stream.Stream<
131+
A,
132+
StreamError|EffectError,
133+
StreamContext|Exclude<EffectContext,Scope.Scope>
134+
>=>{
127135
constinstrumented=Stream.unwrap(
128136
Effect.gen(function*(){
129137
conststartedAt=yield*Clock.currentTimeNanos;

‎apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => {
200200
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
201201
getThreadShellById: ()=>Effect.succeed(Option.none()),
202202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
203204
}),
204205
),
205206
Layer.provide(

‎apps/server/src/orchestration/Layers/OrchestrationEngine.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
306306
Effect.annotateLogs({sequence: commandReadModel.snapshotSequence}),
307307
);
308308

309-
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive)=>
310-
eventStore.readFromSequence(fromSequenceExclusive);
309+
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive,limit)=>
310+
eventStore.readFromSequence(fromSequenceExclusive,limit);
311311

312312
constdispatch: OrchestrationEngineShape["dispatch"]=(command)=>
313313
Effect.gen(function*(){
@@ -329,6 +329,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
329329
getstreamDomainEvents(): OrchestrationEngineShape["streamDomainEvents"]{
330330
returnStream.fromPubSub(eventPubSub);
331331
},
332+
subscribeDomainEvents: Effect.map(PubSub.subscribe(eventPubSub),Stream.fromSubscription),
332333
}satisfiesOrchestrationEngineShape;
333334
});
334335

‎apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20332033
);
20342034
});
20352035

2036+
constgetThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"]=(
2037+
threadId,
2038+
)=>
2039+
sql.withTransaction(Effect.all([getThreadDetailById(threadId),getSnapshotSequence()])).pipe(
2040+
Effect.map(([threadDetail,{ snapshotSequence }])=>
2041+
Option.map(threadDetail,(thread)=>({ snapshotSequence, thread })),
2042+
),
2043+
Effect.mapError((error)=>{
2044+
if(isPersistenceError(error)){
2045+
returnerror;
2046+
}
2047+
returntoPersistenceSqlError("ProjectionSnapshotQuery.getThreadDetailSnapshot:query")(
2048+
error,
2049+
);
2050+
}),
2051+
);
2052+
20362053
return{
20372054
getCommandReadModel,
20382055
getSnapshot,
@@ -2047,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20472064
getFullThreadDiffContext,
20482065
getThreadShellById,
20492066
getThreadDetailById,
2067+
getThreadDetailSnapshot,
20502068
}satisfiesProjectionSnapshotQueryShape;
20512069
});
20522070

‎apps/server/src/orchestration/Services/OrchestrationEngine.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
importtype{OrchestrationCommand,OrchestrationEvent}from"@t3tools/contracts";
1414
import*asContextfrom"effect/Context";
1515
importtype*asEffectfrom"effect/Effect";
16+
importtype*asScopefrom"effect/Scope";
1617
importtype*asStreamfrom"effect/Stream";
1718

1819
importtype{OrchestrationDispatchError}from"../Errors.ts";
@@ -26,10 +27,14 @@ export interface OrchestrationEngineShape {
2627
* Replay persisted orchestration events from an exclusive sequence cursor.
2728
*
2829
* @param fromSequenceExclusive - Sequence cursor (exclusive).
30+
* @param limit - Optional maximum number of events to replay. Defaults to
31+
* the event store's replay cap; pass `Number.MAX_SAFE_INTEGER` for an
32+
* exhaustive catch-up replay.
2933
* @returns Stream containing ordered events.
3034
*/
3135
readonlyreadEvents: (
3236
fromSequenceExclusive: number,
37+
limit?: number,
3338
)=>Stream.Stream<OrchestrationEvent,OrchestrationEventStoreError,never>;
3439

3540
/**
@@ -49,8 +54,26 @@ export interface OrchestrationEngineShape {
4954
* Stream persisted domain events in dispatch order.
5055
*
5156
* This is a hot runtime stream (new events only), not a historical replay.
57+
* The underlying PubSub subscription is only attached once the stream is
58+
* pulled; use `subscribeDomainEvents` when the subscription must be
59+
* attached before other work (e.g. loading a snapshot baseline).
5260
*/
5361
readonlystreamDomainEvents: Stream.Stream<OrchestrationEvent>;
62+
63+
/**
64+
* Attach a domain event subscription immediately and return the stream of
65+
* events it receives.
66+
*
67+
* Unlike `streamDomainEvents`, events published between running this effect
68+
* and pulling the returned stream are buffered by the subscription instead
69+
* of dropped, which is required for snapshot/catch-up + live combinations.
70+
* The subscription is released when the surrounding scope closes.
71+
*/
72+
readonlysubscribeDomainEvents: Effect.Effect<
73+
Stream.Stream<OrchestrationEvent>,
74+
never,
75+
Scope.Scope
76+
>;
5477
}
5578

5679
/**

‎apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
OrchestrationReadModel,
1515
OrchestrationShellSnapshot,
1616
OrchestrationThread,
17+
OrchestrationThreadDetailSnapshot,
1718
OrchestrationThreadShell,
1819
ProjectId,
1920
ThreadId,
@@ -157,6 +158,15 @@ export interface ProjectionSnapshotQueryShape {
157158
readonlygetThreadDetailById: (
158159
threadId: ThreadId,
159160
)=>Effect.Effect<Option.Option<OrchestrationThread>,ProjectionRepositoryError>;
161+
162+
/**
163+
* Read a single active thread detail together with the projection snapshot
164+
* sequence, both observed inside one transaction so the sequence never runs
165+
* ahead of the thread rows it is paired with.
166+
*/
167+
readonlygetThreadDetailSnapshot: (
168+
threadId: ThreadId,
169+
)=>Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>,ProjectionRepositoryError>;
160170
}
161171

162172
/**

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
4545
Effect.fn("environment.orchestration.threadSnapshot")(function*(args){
4646
yield*annotateEnvironmentRequest(args.endpoint.name);
4747
yield*requireEnvironmentScope(AuthOrchestrationReadScope);
48-
const[threadDetail,{ snapshotSequence }]=yield*Effect.all([
49-
projectionSnapshotQuery.getThreadDetailById(args.params.threadId),
50-
projectionSnapshotQuery.getSnapshotSequence(),
51-
]).pipe(
52-
Effect.catch((cause)=>
53-
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
54-
),
55-
);
56-
if(Option.isNone(threadDetail)){
48+
constsnapshot=yield*projectionSnapshotQuery
49+
.getThreadDetailSnapshot(args.params.threadId)
50+
.pipe(
51+
Effect.catch((cause)=>
52+
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
53+
),
54+
);
55+
if(Option.isNone(snapshot)){
5756
returnyield*failEnvironmentNotFound("thread_not_found");
5857
}
59-
return{ snapshotSequence,thread: threadDetail.value};
58+
returnsnapshot.value;
6059
}),
6160
)
6261
.handle(

‎apps/server/src/project/ProjectSetupScriptRunner.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
4343
getFullThreadDiffContext: ()=>Effect.die("unused"),
4444
getThreadShellById: ()=>Effect.die("unused"),
4545
getThreadDetailById: ()=>Effect.die("unused"),
46+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
4647
});
4748

4849
constmakeTerminalManagerLayer=(

‎apps/server/src/provider/Layers/ProviderSessionReaper.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe("ProviderSessionReaper", () => {
209209
: Option.none(),
210210
),
211211
getThreadDetailById: ()=>Effect.die("unused"),
212+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
212213
}),
213214
),
214215
Layer.provideMerge(NodeServices.layer),

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Commit 29677d1

Browse files
committed
Fix thread subscription event loss and snapshot sequence race
- Attach the domain-event PubSub subscription before reading the catch-up replay or snapshot baseline in subscribeThread, so events published while the baseline loads are buffered instead of dropped (new OrchestrationEngine.subscribeDomainEvents, scoped to the RPC stream's lifetime via observeRpcStreamEffect). - Make the afterSequence catch-up replay exhaustive instead of truncating at the event store's default 1,000-event cap, which could silently drop thread events under multi-thread activity. - Read the thread detail and projection snapshot sequence inside one transaction (ProjectionSnapshotQuery.getThreadDetailSnapshot), shared by the HTTP threadSnapshot endpoint and the WS snapshot path, so the reported sequence can never run ahead of the embedded thread.
1 parent fad44e1 commit 29677d1

13 files changed

Lines changed: 117 additions & 47 deletions

‎apps/server/src/checkpointing/CheckpointDiffQuery.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => {
107107
}),
108108
getThreadShellById: ()=>Effect.succeed(Option.none()),
109109
getThreadDetailById: ()=>Effect.succeed(Option.none()),
110+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
110111
}),
111112
),
112113
);
@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
199200
getFullThreadDiffContext: ()=>Effect.die("unused"),
200201
getThreadShellById: ()=>Effect.succeed(Option.none()),
201202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
202204
}),
203205
),
204206
);
@@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {
281283
getFullThreadDiffContext: ()=>Effect.die("unused"),
282284
getThreadShellById: ()=>Effect.succeed(Option.none()),
283285
getThreadDetailById: ()=>Effect.succeed(Option.none()),
286+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
284287
}),
285288
),
286289
);
@@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => {
348351
getFullThreadDiffContext: ()=>Effect.die("unused"),
349352
getThreadShellById: ()=>Effect.succeed(Option.none()),
350353
getThreadDetailById: ()=>Effect.succeed(Option.none()),
354+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
351355
}),
352356
),
353357
);
@@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => {
400404
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
401405
getThreadShellById: ()=>Effect.succeed(Option.none()),
402406
getThreadDetailById: ()=>Effect.succeed(Option.none()),
407+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
403408
}),
404409
),
405410
);

‎apps/server/src/observability/RpcInstrumentation.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55
import*asExitfrom"effect/Exit";
66
import*asMetricfrom"effect/Metric";
77
import*asReferencesfrom"effect/References";
8+
importtype*asScopefrom"effect/Scope";
89
import*asStreamfrom"effect/Stream";
910

1011
import{outcomeFromExit}from"./Attributes.ts";
@@ -123,7 +124,14 @@ export const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectErro
123124
method: string,
124125
effect: Effect.Effect<Stream.Stream<A,StreamError,StreamContext>,EffectError,EffectContext>,
125126
traceAttributes?: Readonly<Record<string,unknown>>,
126-
): Stream.Stream<A,StreamError|EffectError,StreamContext|EffectContext>=>{
127+
// `Stream.unwrap` scopes the setup effect to the stream's lifetime, so a
128+
// `Scope` requirement (e.g. PubSub subscriptions attached before a snapshot
129+
// is loaded) is satisfied by the stream itself rather than the caller.
130+
): Stream.Stream<
131+
A,
132+
StreamError|EffectError,
133+
StreamContext|Exclude<EffectContext,Scope.Scope>
134+
>=>{
127135
constinstrumented=Stream.unwrap(
128136
Effect.gen(function*(){
129137
conststartedAt=yield*Clock.currentTimeNanos;

‎apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => {
200200
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
201201
getThreadShellById: ()=>Effect.succeed(Option.none()),
202202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
203204
}),
204205
),
205206
Layer.provide(

‎apps/server/src/orchestration/Layers/OrchestrationEngine.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
306306
Effect.annotateLogs({sequence: commandReadModel.snapshotSequence}),
307307
);
308308

309-
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive)=>
310-
eventStore.readFromSequence(fromSequenceExclusive);
309+
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive,limit)=>
310+
eventStore.readFromSequence(fromSequenceExclusive,limit);
311311

312312
constdispatch: OrchestrationEngineShape["dispatch"]=(command)=>
313313
Effect.gen(function*(){
@@ -329,6 +329,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
329329
getstreamDomainEvents(): OrchestrationEngineShape["streamDomainEvents"]{
330330
returnStream.fromPubSub(eventPubSub);
331331
},
332+
subscribeDomainEvents: Effect.map(PubSub.subscribe(eventPubSub),Stream.fromSubscription),
332333
}satisfiesOrchestrationEngineShape;
333334
});
334335

‎apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20332033
);
20342034
});
20352035

2036+
constgetThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"]=(
2037+
threadId,
2038+
)=>
2039+
sql.withTransaction(Effect.all([getThreadDetailById(threadId),getSnapshotSequence()])).pipe(
2040+
Effect.map(([threadDetail,{ snapshotSequence }])=>
2041+
Option.map(threadDetail,(thread)=>({ snapshotSequence, thread })),
2042+
),
2043+
Effect.mapError((error)=>{
2044+
if(isPersistenceError(error)){
2045+
returnerror;
2046+
}
2047+
returntoPersistenceSqlError("ProjectionSnapshotQuery.getThreadDetailSnapshot:query")(
2048+
error,
2049+
);
2050+
}),
2051+
);
2052+
20362053
return{
20372054
getCommandReadModel,
20382055
getSnapshot,
@@ -2047,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20472064
getFullThreadDiffContext,
20482065
getThreadShellById,
20492066
getThreadDetailById,
2067+
getThreadDetailSnapshot,
20502068
}satisfiesProjectionSnapshotQueryShape;
20512069
});
20522070

‎apps/server/src/orchestration/Services/OrchestrationEngine.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
importtype{OrchestrationCommand,OrchestrationEvent}from"@t3tools/contracts";
1414
import*asContextfrom"effect/Context";
1515
importtype*asEffectfrom"effect/Effect";
16+
importtype*asScopefrom"effect/Scope";
1617
importtype*asStreamfrom"effect/Stream";
1718

1819
importtype{OrchestrationDispatchError}from"../Errors.ts";
@@ -26,10 +27,14 @@ export interface OrchestrationEngineShape {
2627
* Replay persisted orchestration events from an exclusive sequence cursor.
2728
*
2829
* @param fromSequenceExclusive - Sequence cursor (exclusive).
30+
* @param limit - Optional maximum number of events to replay. Defaults to
31+
* the event store's replay cap; pass `Number.MAX_SAFE_INTEGER` for an
32+
* exhaustive catch-up replay.
2933
* @returns Stream containing ordered events.
3034
*/
3135
readonlyreadEvents: (
3236
fromSequenceExclusive: number,
37+
limit?: number,
3338
)=>Stream.Stream<OrchestrationEvent,OrchestrationEventStoreError,never>;
3439

3540
/**
@@ -49,8 +54,26 @@ export interface OrchestrationEngineShape {
4954
* Stream persisted domain events in dispatch order.
5055
*
5156
* This is a hot runtime stream (new events only), not a historical replay.
57+
* The underlying PubSub subscription is only attached once the stream is
58+
* pulled; use `subscribeDomainEvents` when the subscription must be
59+
* attached before other work (e.g. loading a snapshot baseline).
5260
*/
5361
readonlystreamDomainEvents: Stream.Stream<OrchestrationEvent>;
62+
63+
/**
64+
* Attach a domain event subscription immediately and return the stream of
65+
* events it receives.
66+
*
67+
* Unlike `streamDomainEvents`, events published between running this effect
68+
* and pulling the returned stream are buffered by the subscription instead
69+
* of dropped, which is required for snapshot/catch-up + live combinations.
70+
* The subscription is released when the surrounding scope closes.
71+
*/
72+
readonlysubscribeDomainEvents: Effect.Effect<
73+
Stream.Stream<OrchestrationEvent>,
74+
never,
75+
Scope.Scope
76+
>;
5477
}
5578

5679
/**

‎apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
OrchestrationReadModel,
1515
OrchestrationShellSnapshot,
1616
OrchestrationThread,
17+
OrchestrationThreadDetailSnapshot,
1718
OrchestrationThreadShell,
1819
ProjectId,
1920
ThreadId,
@@ -157,6 +158,15 @@ export interface ProjectionSnapshotQueryShape {
157158
readonlygetThreadDetailById: (
158159
threadId: ThreadId,
159160
)=>Effect.Effect<Option.Option<OrchestrationThread>,ProjectionRepositoryError>;
161+
162+
/**
163+
* Read a single active thread detail together with the projection snapshot
164+
* sequence, both observed inside one transaction so the sequence never runs
165+
* ahead of the thread rows it is paired with.
166+
*/
167+
readonlygetThreadDetailSnapshot: (
168+
threadId: ThreadId,
169+
)=>Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>,ProjectionRepositoryError>;
160170
}
161171

162172
/**

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
4545
Effect.fn("environment.orchestration.threadSnapshot")(function*(args){
4646
yield*annotateEnvironmentRequest(args.endpoint.name);
4747
yield*requireEnvironmentScope(AuthOrchestrationReadScope);
48-
const[threadDetail,{ snapshotSequence }]=yield*Effect.all([
49-
projectionSnapshotQuery.getThreadDetailById(args.params.threadId),
50-
projectionSnapshotQuery.getSnapshotSequence(),
51-
]).pipe(
52-
Effect.catch((cause)=>
53-
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
54-
),
55-
);
56-
if(Option.isNone(threadDetail)){
48+
constsnapshot=yield*projectionSnapshotQuery
49+
.getThreadDetailSnapshot(args.params.threadId)
50+
.pipe(
51+
Effect.catch((cause)=>
52+
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
53+
),
54+
);
55+
if(Option.isNone(snapshot)){
5756
returnyield*failEnvironmentNotFound("thread_not_found");
5857
}
59-
return{ snapshotSequence,thread: threadDetail.value};
58+
returnsnapshot.value;
6059
}),
6160
)
6261
.handle(

‎apps/server/src/project/ProjectSetupScriptRunner.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
4343
getFullThreadDiffContext: ()=>Effect.die("unused"),
4444
getThreadShellById: ()=>Effect.die("unused"),
4545
getThreadDetailById: ()=>Effect.die("unused"),
46+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
4647
});
4748

4849
constmakeTerminalManagerLayer=(

‎apps/server/src/provider/Layers/ProviderSessionReaper.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe("ProviderSessionReaper", () => {
209209
: Option.none(),
210210
),
211211
getThreadDetailById: ()=>Effect.die("unused"),
212+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
212213
}),
213214
),
214215
Layer.provideMerge(NodeServices.layer),

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 29677d1

Browse files
committed
Fix thread subscription event loss and snapshot sequence race
- Attach the domain-event PubSub subscription before reading the catch-up replay or snapshot baseline in subscribeThread, so events published while the baseline loads are buffered instead of dropped (new OrchestrationEngine.subscribeDomainEvents, scoped to the RPC stream's lifetime via observeRpcStreamEffect). - Make the afterSequence catch-up replay exhaustive instead of truncating at the event store's default 1,000-event cap, which could silently drop thread events under multi-thread activity. - Read the thread detail and projection snapshot sequence inside one transaction (ProjectionSnapshotQuery.getThreadDetailSnapshot), shared by the HTTP threadSnapshot endpoint and the WS snapshot path, so the reported sequence can never run ahead of the embedded thread.
1 parent fad44e1 commit 29677d1

13 files changed

Lines changed: 117 additions & 47 deletions

‎apps/server/src/checkpointing/CheckpointDiffQuery.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => {
107107
}),
108108
getThreadShellById: ()=>Effect.succeed(Option.none()),
109109
getThreadDetailById: ()=>Effect.succeed(Option.none()),
110+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
110111
}),
111112
),
112113
);
@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
199200
getFullThreadDiffContext: ()=>Effect.die("unused"),
200201
getThreadShellById: ()=>Effect.succeed(Option.none()),
201202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
202204
}),
203205
),
204206
);
@@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {
281283
getFullThreadDiffContext: ()=>Effect.die("unused"),
282284
getThreadShellById: ()=>Effect.succeed(Option.none()),
283285
getThreadDetailById: ()=>Effect.succeed(Option.none()),
286+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
284287
}),
285288
),
286289
);
@@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => {
348351
getFullThreadDiffContext: ()=>Effect.die("unused"),
349352
getThreadShellById: ()=>Effect.succeed(Option.none()),
350353
getThreadDetailById: ()=>Effect.succeed(Option.none()),
354+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
351355
}),
352356
),
353357
);
@@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => {
400404
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
401405
getThreadShellById: ()=>Effect.succeed(Option.none()),
402406
getThreadDetailById: ()=>Effect.succeed(Option.none()),
407+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
403408
}),
404409
),
405410
);

‎apps/server/src/observability/RpcInstrumentation.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55
import*asExitfrom"effect/Exit";
66
import*asMetricfrom"effect/Metric";
77
import*asReferencesfrom"effect/References";
8+
importtype*asScopefrom"effect/Scope";
89
import*asStreamfrom"effect/Stream";
910

1011
import{outcomeFromExit}from"./Attributes.ts";
@@ -123,7 +124,14 @@ export const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectErro
123124
method: string,
124125
effect: Effect.Effect<Stream.Stream<A,StreamError,StreamContext>,EffectError,EffectContext>,
125126
traceAttributes?: Readonly<Record<string,unknown>>,
126-
): Stream.Stream<A,StreamError|EffectError,StreamContext|EffectContext>=>{
127+
// `Stream.unwrap` scopes the setup effect to the stream's lifetime, so a
128+
// `Scope` requirement (e.g. PubSub subscriptions attached before a snapshot
129+
// is loaded) is satisfied by the stream itself rather than the caller.
130+
): Stream.Stream<
131+
A,
132+
StreamError|EffectError,
133+
StreamContext|Exclude<EffectContext,Scope.Scope>
134+
>=>{
127135
constinstrumented=Stream.unwrap(
128136
Effect.gen(function*(){
129137
conststartedAt=yield*Clock.currentTimeNanos;

‎apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => {
200200
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
201201
getThreadShellById: ()=>Effect.succeed(Option.none()),
202202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
203204
}),
204205
),
205206
Layer.provide(

‎apps/server/src/orchestration/Layers/OrchestrationEngine.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
306306
Effect.annotateLogs({sequence: commandReadModel.snapshotSequence}),
307307
);
308308

309-
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive)=>
310-
eventStore.readFromSequence(fromSequenceExclusive);
309+
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive,limit)=>
310+
eventStore.readFromSequence(fromSequenceExclusive,limit);
311311

312312
constdispatch: OrchestrationEngineShape["dispatch"]=(command)=>
313313
Effect.gen(function*(){
@@ -329,6 +329,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
329329
getstreamDomainEvents(): OrchestrationEngineShape["streamDomainEvents"]{
330330
returnStream.fromPubSub(eventPubSub);
331331
},
332+
subscribeDomainEvents: Effect.map(PubSub.subscribe(eventPubSub),Stream.fromSubscription),
332333
}satisfiesOrchestrationEngineShape;
333334
});
334335

‎apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20332033
);
20342034
});
20352035

2036+
constgetThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"]=(
2037+
threadId,
2038+
)=>
2039+
sql.withTransaction(Effect.all([getThreadDetailById(threadId),getSnapshotSequence()])).pipe(
2040+
Effect.map(([threadDetail,{ snapshotSequence }])=>
2041+
Option.map(threadDetail,(thread)=>({ snapshotSequence, thread })),
2042+
),
2043+
Effect.mapError((error)=>{
2044+
if(isPersistenceError(error)){
2045+
returnerror;
2046+
}
2047+
returntoPersistenceSqlError("ProjectionSnapshotQuery.getThreadDetailSnapshot:query")(
2048+
error,
2049+
);
2050+
}),
2051+
);
2052+
20362053
return{
20372054
getCommandReadModel,
20382055
getSnapshot,
@@ -2047,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20472064
getFullThreadDiffContext,
20482065
getThreadShellById,
20492066
getThreadDetailById,
2067+
getThreadDetailSnapshot,
20502068
}satisfiesProjectionSnapshotQueryShape;
20512069
});
20522070

‎apps/server/src/orchestration/Services/OrchestrationEngine.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
importtype{OrchestrationCommand,OrchestrationEvent}from"@t3tools/contracts";
1414
import*asContextfrom"effect/Context";
1515
importtype*asEffectfrom"effect/Effect";
16+
importtype*asScopefrom"effect/Scope";
1617
importtype*asStreamfrom"effect/Stream";
1718

1819
importtype{OrchestrationDispatchError}from"../Errors.ts";
@@ -26,10 +27,14 @@ export interface OrchestrationEngineShape {
2627
* Replay persisted orchestration events from an exclusive sequence cursor.
2728
*
2829
* @param fromSequenceExclusive - Sequence cursor (exclusive).
30+
* @param limit - Optional maximum number of events to replay. Defaults to
31+
* the event store's replay cap; pass `Number.MAX_SAFE_INTEGER` for an
32+
* exhaustive catch-up replay.
2933
* @returns Stream containing ordered events.
3034
*/
3135
readonlyreadEvents: (
3236
fromSequenceExclusive: number,
37+
limit?: number,
3338
)=>Stream.Stream<OrchestrationEvent,OrchestrationEventStoreError,never>;
3439

3540
/**
@@ -49,8 +54,26 @@ export interface OrchestrationEngineShape {
4954
* Stream persisted domain events in dispatch order.
5055
*
5156
* This is a hot runtime stream (new events only), not a historical replay.
57+
* The underlying PubSub subscription is only attached once the stream is
58+
* pulled; use `subscribeDomainEvents` when the subscription must be
59+
* attached before other work (e.g. loading a snapshot baseline).
5260
*/
5361
readonlystreamDomainEvents: Stream.Stream<OrchestrationEvent>;
62+
63+
/**
64+
* Attach a domain event subscription immediately and return the stream of
65+
* events it receives.
66+
*
67+
* Unlike `streamDomainEvents`, events published between running this effect
68+
* and pulling the returned stream are buffered by the subscription instead
69+
* of dropped, which is required for snapshot/catch-up + live combinations.
70+
* The subscription is released when the surrounding scope closes.
71+
*/
72+
readonlysubscribeDomainEvents: Effect.Effect<
73+
Stream.Stream<OrchestrationEvent>,
74+
never,
75+
Scope.Scope
76+
>;
5477
}
5578

5679
/**

‎apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
OrchestrationReadModel,
1515
OrchestrationShellSnapshot,
1616
OrchestrationThread,
17+
OrchestrationThreadDetailSnapshot,
1718
OrchestrationThreadShell,
1819
ProjectId,
1920
ThreadId,
@@ -157,6 +158,15 @@ export interface ProjectionSnapshotQueryShape {
157158
readonlygetThreadDetailById: (
158159
threadId: ThreadId,
159160
)=>Effect.Effect<Option.Option<OrchestrationThread>,ProjectionRepositoryError>;
161+
162+
/**
163+
* Read a single active thread detail together with the projection snapshot
164+
* sequence, both observed inside one transaction so the sequence never runs
165+
* ahead of the thread rows it is paired with.
166+
*/
167+
readonlygetThreadDetailSnapshot: (
168+
threadId: ThreadId,
169+
)=>Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>,ProjectionRepositoryError>;
160170
}
161171

162172
/**

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
4545
Effect.fn("environment.orchestration.threadSnapshot")(function*(args){
4646
yield*annotateEnvironmentRequest(args.endpoint.name);
4747
yield*requireEnvironmentScope(AuthOrchestrationReadScope);
48-
const[threadDetail,{ snapshotSequence }]=yield*Effect.all([
49-
projectionSnapshotQuery.getThreadDetailById(args.params.threadId),
50-
projectionSnapshotQuery.getSnapshotSequence(),
51-
]).pipe(
52-
Effect.catch((cause)=>
53-
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
54-
),
55-
);
56-
if(Option.isNone(threadDetail)){
48+
constsnapshot=yield*projectionSnapshotQuery
49+
.getThreadDetailSnapshot(args.params.threadId)
50+
.pipe(
51+
Effect.catch((cause)=>
52+
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
53+
),
54+
);
55+
if(Option.isNone(snapshot)){
5756
returnyield*failEnvironmentNotFound("thread_not_found");
5857
}
59-
return{ snapshotSequence,thread: threadDetail.value};
58+
returnsnapshot.value;
6059
}),
6160
)
6261
.handle(

‎apps/server/src/project/ProjectSetupScriptRunner.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
4343
getFullThreadDiffContext: ()=>Effect.die("unused"),
4444
getThreadShellById: ()=>Effect.die("unused"),
4545
getThreadDetailById: ()=>Effect.die("unused"),
46+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
4647
});
4748

4849
constmakeTerminalManagerLayer=(

‎apps/server/src/provider/Layers/ProviderSessionReaper.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe("ProviderSessionReaper", () => {
209209
: Option.none(),
210210
),
211211
getThreadDetailById: ()=>Effect.die("unused"),
212+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
212213
}),
213214
),
214215
Layer.provideMerge(NodeServices.layer),

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 29677d1

Browse files
committed
Fix thread subscription event loss and snapshot sequence race
- Attach the domain-event PubSub subscription before reading the catch-up replay or snapshot baseline in subscribeThread, so events published while the baseline loads are buffered instead of dropped (new OrchestrationEngine.subscribeDomainEvents, scoped to the RPC stream's lifetime via observeRpcStreamEffect). - Make the afterSequence catch-up replay exhaustive instead of truncating at the event store's default 1,000-event cap, which could silently drop thread events under multi-thread activity. - Read the thread detail and projection snapshot sequence inside one transaction (ProjectionSnapshotQuery.getThreadDetailSnapshot), shared by the HTTP threadSnapshot endpoint and the WS snapshot path, so the reported sequence can never run ahead of the embedded thread.
1 parent fad44e1 commit 29677d1

13 files changed

Lines changed: 117 additions & 47 deletions

‎apps/server/src/checkpointing/CheckpointDiffQuery.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => {
107107
}),
108108
getThreadShellById: ()=>Effect.succeed(Option.none()),
109109
getThreadDetailById: ()=>Effect.succeed(Option.none()),
110+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
110111
}),
111112
),
112113
);
@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
199200
getFullThreadDiffContext: ()=>Effect.die("unused"),
200201
getThreadShellById: ()=>Effect.succeed(Option.none()),
201202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
202204
}),
203205
),
204206
);
@@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {
281283
getFullThreadDiffContext: ()=>Effect.die("unused"),
282284
getThreadShellById: ()=>Effect.succeed(Option.none()),
283285
getThreadDetailById: ()=>Effect.succeed(Option.none()),
286+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
284287
}),
285288
),
286289
);
@@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => {
348351
getFullThreadDiffContext: ()=>Effect.die("unused"),
349352
getThreadShellById: ()=>Effect.succeed(Option.none()),
350353
getThreadDetailById: ()=>Effect.succeed(Option.none()),
354+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
351355
}),
352356
),
353357
);
@@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => {
400404
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
401405
getThreadShellById: ()=>Effect.succeed(Option.none()),
402406
getThreadDetailById: ()=>Effect.succeed(Option.none()),
407+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
403408
}),
404409
),
405410
);

‎apps/server/src/observability/RpcInstrumentation.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55
import*asExitfrom"effect/Exit";
66
import*asMetricfrom"effect/Metric";
77
import*asReferencesfrom"effect/References";
8+
importtype*asScopefrom"effect/Scope";
89
import*asStreamfrom"effect/Stream";
910

1011
import{outcomeFromExit}from"./Attributes.ts";
@@ -123,7 +124,14 @@ export const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectErro
123124
method: string,
124125
effect: Effect.Effect<Stream.Stream<A,StreamError,StreamContext>,EffectError,EffectContext>,
125126
traceAttributes?: Readonly<Record<string,unknown>>,
126-
): Stream.Stream<A,StreamError|EffectError,StreamContext|EffectContext>=>{
127+
// `Stream.unwrap` scopes the setup effect to the stream's lifetime, so a
128+
// `Scope` requirement (e.g. PubSub subscriptions attached before a snapshot
129+
// is loaded) is satisfied by the stream itself rather than the caller.
130+
): Stream.Stream<
131+
A,
132+
StreamError|EffectError,
133+
StreamContext|Exclude<EffectContext,Scope.Scope>
134+
>=>{
127135
constinstrumented=Stream.unwrap(
128136
Effect.gen(function*(){
129137
conststartedAt=yield*Clock.currentTimeNanos;

‎apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => {
200200
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
201201
getThreadShellById: ()=>Effect.succeed(Option.none()),
202202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
203204
}),
204205
),
205206
Layer.provide(

‎apps/server/src/orchestration/Layers/OrchestrationEngine.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
306306
Effect.annotateLogs({sequence: commandReadModel.snapshotSequence}),
307307
);
308308

309-
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive)=>
310-
eventStore.readFromSequence(fromSequenceExclusive);
309+
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive,limit)=>
310+
eventStore.readFromSequence(fromSequenceExclusive,limit);
311311

312312
constdispatch: OrchestrationEngineShape["dispatch"]=(command)=>
313313
Effect.gen(function*(){
@@ -329,6 +329,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
329329
getstreamDomainEvents(): OrchestrationEngineShape["streamDomainEvents"]{
330330
returnStream.fromPubSub(eventPubSub);
331331
},
332+
subscribeDomainEvents: Effect.map(PubSub.subscribe(eventPubSub),Stream.fromSubscription),
332333
}satisfiesOrchestrationEngineShape;
333334
});
334335

‎apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20332033
);
20342034
});
20352035

2036+
constgetThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"]=(
2037+
threadId,
2038+
)=>
2039+
sql.withTransaction(Effect.all([getThreadDetailById(threadId),getSnapshotSequence()])).pipe(
2040+
Effect.map(([threadDetail,{ snapshotSequence }])=>
2041+
Option.map(threadDetail,(thread)=>({ snapshotSequence, thread })),
2042+
),
2043+
Effect.mapError((error)=>{
2044+
if(isPersistenceError(error)){
2045+
returnerror;
2046+
}
2047+
returntoPersistenceSqlError("ProjectionSnapshotQuery.getThreadDetailSnapshot:query")(
2048+
error,
2049+
);
2050+
}),
2051+
);
2052+
20362053
return{
20372054
getCommandReadModel,
20382055
getSnapshot,
@@ -2047,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20472064
getFullThreadDiffContext,
20482065
getThreadShellById,
20492066
getThreadDetailById,
2067+
getThreadDetailSnapshot,
20502068
}satisfiesProjectionSnapshotQueryShape;
20512069
});
20522070

‎apps/server/src/orchestration/Services/OrchestrationEngine.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
importtype{OrchestrationCommand,OrchestrationEvent}from"@t3tools/contracts";
1414
import*asContextfrom"effect/Context";
1515
importtype*asEffectfrom"effect/Effect";
16+
importtype*asScopefrom"effect/Scope";
1617
importtype*asStreamfrom"effect/Stream";
1718

1819
importtype{OrchestrationDispatchError}from"../Errors.ts";
@@ -26,10 +27,14 @@ export interface OrchestrationEngineShape {
2627
* Replay persisted orchestration events from an exclusive sequence cursor.
2728
*
2829
* @param fromSequenceExclusive - Sequence cursor (exclusive).
30+
* @param limit - Optional maximum number of events to replay. Defaults to
31+
* the event store's replay cap; pass `Number.MAX_SAFE_INTEGER` for an
32+
* exhaustive catch-up replay.
2933
* @returns Stream containing ordered events.
3034
*/
3135
readonlyreadEvents: (
3236
fromSequenceExclusive: number,
37+
limit?: number,
3338
)=>Stream.Stream<OrchestrationEvent,OrchestrationEventStoreError,never>;
3439

3540
/**
@@ -49,8 +54,26 @@ export interface OrchestrationEngineShape {
4954
* Stream persisted domain events in dispatch order.
5055
*
5156
* This is a hot runtime stream (new events only), not a historical replay.
57+
* The underlying PubSub subscription is only attached once the stream is
58+
* pulled; use `subscribeDomainEvents` when the subscription must be
59+
* attached before other work (e.g. loading a snapshot baseline).
5260
*/
5361
readonlystreamDomainEvents: Stream.Stream<OrchestrationEvent>;
62+
63+
/**
64+
* Attach a domain event subscription immediately and return the stream of
65+
* events it receives.
66+
*
67+
* Unlike `streamDomainEvents`, events published between running this effect
68+
* and pulling the returned stream are buffered by the subscription instead
69+
* of dropped, which is required for snapshot/catch-up + live combinations.
70+
* The subscription is released when the surrounding scope closes.
71+
*/
72+
readonlysubscribeDomainEvents: Effect.Effect<
73+
Stream.Stream<OrchestrationEvent>,
74+
never,
75+
Scope.Scope
76+
>;
5477
}
5578

5679
/**

‎apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
OrchestrationReadModel,
1515
OrchestrationShellSnapshot,
1616
OrchestrationThread,
17+
OrchestrationThreadDetailSnapshot,
1718
OrchestrationThreadShell,
1819
ProjectId,
1920
ThreadId,
@@ -157,6 +158,15 @@ export interface ProjectionSnapshotQueryShape {
157158
readonlygetThreadDetailById: (
158159
threadId: ThreadId,
159160
)=>Effect.Effect<Option.Option<OrchestrationThread>,ProjectionRepositoryError>;
161+
162+
/**
163+
* Read a single active thread detail together with the projection snapshot
164+
* sequence, both observed inside one transaction so the sequence never runs
165+
* ahead of the thread rows it is paired with.
166+
*/
167+
readonlygetThreadDetailSnapshot: (
168+
threadId: ThreadId,
169+
)=>Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>,ProjectionRepositoryError>;
160170
}
161171

162172
/**

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
4545
Effect.fn("environment.orchestration.threadSnapshot")(function*(args){
4646
yield*annotateEnvironmentRequest(args.endpoint.name);
4747
yield*requireEnvironmentScope(AuthOrchestrationReadScope);
48-
const[threadDetail,{ snapshotSequence }]=yield*Effect.all([
49-
projectionSnapshotQuery.getThreadDetailById(args.params.threadId),
50-
projectionSnapshotQuery.getSnapshotSequence(),
51-
]).pipe(
52-
Effect.catch((cause)=>
53-
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
54-
),
55-
);
56-
if(Option.isNone(threadDetail)){
48+
constsnapshot=yield*projectionSnapshotQuery
49+
.getThreadDetailSnapshot(args.params.threadId)
50+
.pipe(
51+
Effect.catch((cause)=>
52+
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
53+
),
54+
);
55+
if(Option.isNone(snapshot)){
5756
returnyield*failEnvironmentNotFound("thread_not_found");
5857
}
59-
return{ snapshotSequence,thread: threadDetail.value};
58+
returnsnapshot.value;
6059
}),
6160
)
6261
.handle(

‎apps/server/src/project/ProjectSetupScriptRunner.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
4343
getFullThreadDiffContext: ()=>Effect.die("unused"),
4444
getThreadShellById: ()=>Effect.die("unused"),
4545
getThreadDetailById: ()=>Effect.die("unused"),
46+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
4647
});
4748

4849
constmakeTerminalManagerLayer=(

‎apps/server/src/provider/Layers/ProviderSessionReaper.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe("ProviderSessionReaper", () => {
209209
: Option.none(),
210210
),
211211
getThreadDetailById: ()=>Effect.die("unused"),
212+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
212213
}),
213214
),
214215
Layer.provideMerge(NodeServices.layer),

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Commit 29677d1

Browse files
committed
Fix thread subscription event loss and snapshot sequence race
- Attach the domain-event PubSub subscription before reading the catch-up replay or snapshot baseline in subscribeThread, so events published while the baseline loads are buffered instead of dropped (new OrchestrationEngine.subscribeDomainEvents, scoped to the RPC stream's lifetime via observeRpcStreamEffect). - Make the afterSequence catch-up replay exhaustive instead of truncating at the event store's default 1,000-event cap, which could silently drop thread events under multi-thread activity. - Read the thread detail and projection snapshot sequence inside one transaction (ProjectionSnapshotQuery.getThreadDetailSnapshot), shared by the HTTP threadSnapshot endpoint and the WS snapshot path, so the reported sequence can never run ahead of the embedded thread.
1 parent fad44e1 commit 29677d1

13 files changed

Lines changed: 117 additions & 47 deletions

‎apps/server/src/checkpointing/CheckpointDiffQuery.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => {
107107
}),
108108
getThreadShellById: ()=>Effect.succeed(Option.none()),
109109
getThreadDetailById: ()=>Effect.succeed(Option.none()),
110+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
110111
}),
111112
),
112113
);
@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
199200
getFullThreadDiffContext: ()=>Effect.die("unused"),
200201
getThreadShellById: ()=>Effect.succeed(Option.none()),
201202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
202204
}),
203205
),
204206
);
@@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {
281283
getFullThreadDiffContext: ()=>Effect.die("unused"),
282284
getThreadShellById: ()=>Effect.succeed(Option.none()),
283285
getThreadDetailById: ()=>Effect.succeed(Option.none()),
286+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
284287
}),
285288
),
286289
);
@@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => {
348351
getFullThreadDiffContext: ()=>Effect.die("unused"),
349352
getThreadShellById: ()=>Effect.succeed(Option.none()),
350353
getThreadDetailById: ()=>Effect.succeed(Option.none()),
354+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
351355
}),
352356
),
353357
);
@@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => {
400404
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
401405
getThreadShellById: ()=>Effect.succeed(Option.none()),
402406
getThreadDetailById: ()=>Effect.succeed(Option.none()),
407+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
403408
}),
404409
),
405410
);

‎apps/server/src/observability/RpcInstrumentation.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55
import*asExitfrom"effect/Exit";
66
import*asMetricfrom"effect/Metric";
77
import*asReferencesfrom"effect/References";
8+
importtype*asScopefrom"effect/Scope";
89
import*asStreamfrom"effect/Stream";
910

1011
import{outcomeFromExit}from"./Attributes.ts";
@@ -123,7 +124,14 @@ export const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectErro
123124
method: string,
124125
effect: Effect.Effect<Stream.Stream<A,StreamError,StreamContext>,EffectError,EffectContext>,
125126
traceAttributes?: Readonly<Record<string,unknown>>,
126-
): Stream.Stream<A,StreamError|EffectError,StreamContext|EffectContext>=>{
127+
// `Stream.unwrap` scopes the setup effect to the stream's lifetime, so a
128+
// `Scope` requirement (e.g. PubSub subscriptions attached before a snapshot
129+
// is loaded) is satisfied by the stream itself rather than the caller.
130+
): Stream.Stream<
131+
A,
132+
StreamError|EffectError,
133+
StreamContext|Exclude<EffectContext,Scope.Scope>
134+
>=>{
127135
constinstrumented=Stream.unwrap(
128136
Effect.gen(function*(){
129137
conststartedAt=yield*Clock.currentTimeNanos;

‎apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => {
200200
getFullThreadDiffContext: ()=>Effect.succeed(Option.none()),
201201
getThreadShellById: ()=>Effect.succeed(Option.none()),
202202
getThreadDetailById: ()=>Effect.succeed(Option.none()),
203+
getThreadDetailSnapshot: ()=>Effect.succeed(Option.none()),
203204
}),
204205
),
205206
Layer.provide(

‎apps/server/src/orchestration/Layers/OrchestrationEngine.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
306306
Effect.annotateLogs({sequence: commandReadModel.snapshotSequence}),
307307
);
308308

309-
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive)=>
310-
eventStore.readFromSequence(fromSequenceExclusive);
309+
constreadEvents: OrchestrationEngineShape["readEvents"]=(fromSequenceExclusive,limit)=>
310+
eventStore.readFromSequence(fromSequenceExclusive,limit);
311311

312312
constdispatch: OrchestrationEngineShape["dispatch"]=(command)=>
313313
Effect.gen(function*(){
@@ -329,6 +329,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
329329
getstreamDomainEvents(): OrchestrationEngineShape["streamDomainEvents"]{
330330
returnStream.fromPubSub(eventPubSub);
331331
},
332+
subscribeDomainEvents: Effect.map(PubSub.subscribe(eventPubSub),Stream.fromSubscription),
332333
}satisfiesOrchestrationEngineShape;
333334
});
334335

‎apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20332033
);
20342034
});
20352035

2036+
constgetThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"]=(
2037+
threadId,
2038+
)=>
2039+
sql.withTransaction(Effect.all([getThreadDetailById(threadId),getSnapshotSequence()])).pipe(
2040+
Effect.map(([threadDetail,{ snapshotSequence }])=>
2041+
Option.map(threadDetail,(thread)=>({ snapshotSequence, thread })),
2042+
),
2043+
Effect.mapError((error)=>{
2044+
if(isPersistenceError(error)){
2045+
returnerror;
2046+
}
2047+
returntoPersistenceSqlError("ProjectionSnapshotQuery.getThreadDetailSnapshot:query")(
2048+
error,
2049+
);
2050+
}),
2051+
);
2052+
20362053
return{
20372054
getCommandReadModel,
20382055
getSnapshot,
@@ -2047,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20472064
getFullThreadDiffContext,
20482065
getThreadShellById,
20492066
getThreadDetailById,
2067+
getThreadDetailSnapshot,
20502068
}satisfiesProjectionSnapshotQueryShape;
20512069
});
20522070

‎apps/server/src/orchestration/Services/OrchestrationEngine.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
importtype{OrchestrationCommand,OrchestrationEvent}from"@t3tools/contracts";
1414
import*asContextfrom"effect/Context";
1515
importtype*asEffectfrom"effect/Effect";
16+
importtype*asScopefrom"effect/Scope";
1617
importtype*asStreamfrom"effect/Stream";
1718

1819
importtype{OrchestrationDispatchError}from"../Errors.ts";
@@ -26,10 +27,14 @@ export interface OrchestrationEngineShape {
2627
* Replay persisted orchestration events from an exclusive sequence cursor.
2728
*
2829
* @param fromSequenceExclusive - Sequence cursor (exclusive).
30+
* @param limit - Optional maximum number of events to replay. Defaults to
31+
* the event store's replay cap; pass `Number.MAX_SAFE_INTEGER` for an
32+
* exhaustive catch-up replay.
2933
* @returns Stream containing ordered events.
3034
*/
3135
readonlyreadEvents: (
3236
fromSequenceExclusive: number,
37+
limit?: number,
3338
)=>Stream.Stream<OrchestrationEvent,OrchestrationEventStoreError,never>;
3439

3540
/**
@@ -49,8 +54,26 @@ export interface OrchestrationEngineShape {
4954
* Stream persisted domain events in dispatch order.
5055
*
5156
* This is a hot runtime stream (new events only), not a historical replay.
57+
* The underlying PubSub subscription is only attached once the stream is
58+
* pulled; use `subscribeDomainEvents` when the subscription must be
59+
* attached before other work (e.g. loading a snapshot baseline).
5260
*/
5361
readonlystreamDomainEvents: Stream.Stream<OrchestrationEvent>;
62+
63+
/**
64+
* Attach a domain event subscription immediately and return the stream of
65+
* events it receives.
66+
*
67+
* Unlike `streamDomainEvents`, events published between running this effect
68+
* and pulling the returned stream are buffered by the subscription instead
69+
* of dropped, which is required for snapshot/catch-up + live combinations.
70+
* The subscription is released when the surrounding scope closes.
71+
*/
72+
readonlysubscribeDomainEvents: Effect.Effect<
73+
Stream.Stream<OrchestrationEvent>,
74+
never,
75+
Scope.Scope
76+
>;
5477
}
5578

5679
/**

‎apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
OrchestrationReadModel,
1515
OrchestrationShellSnapshot,
1616
OrchestrationThread,
17+
OrchestrationThreadDetailSnapshot,
1718
OrchestrationThreadShell,
1819
ProjectId,
1920
ThreadId,
@@ -157,6 +158,15 @@ export interface ProjectionSnapshotQueryShape {
157158
readonlygetThreadDetailById: (
158159
threadId: ThreadId,
159160
)=>Effect.Effect<Option.Option<OrchestrationThread>,ProjectionRepositoryError>;
161+
162+
/**
163+
* Read a single active thread detail together with the projection snapshot
164+
* sequence, both observed inside one transaction so the sequence never runs
165+
* ahead of the thread rows it is paired with.
166+
*/
167+
readonlygetThreadDetailSnapshot: (
168+
threadId: ThreadId,
169+
)=>Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>,ProjectionRepositoryError>;
160170
}
161171

162172
/**

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
4545
Effect.fn("environment.orchestration.threadSnapshot")(function*(args){
4646
yield*annotateEnvironmentRequest(args.endpoint.name);
4747
yield*requireEnvironmentScope(AuthOrchestrationReadScope);
48-
const[threadDetail,{ snapshotSequence }]=yield*Effect.all([
49-
projectionSnapshotQuery.getThreadDetailById(args.params.threadId),
50-
projectionSnapshotQuery.getSnapshotSequence(),
51-
]).pipe(
52-
Effect.catch((cause)=>
53-
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
54-
),
55-
);
56-
if(Option.isNone(threadDetail)){
48+
constsnapshot=yield*projectionSnapshotQuery
49+
.getThreadDetailSnapshot(args.params.threadId)
50+
.pipe(
51+
Effect.catch((cause)=>
52+
failEnvironmentInternal("orchestration_thread_snapshot_failed",cause),
53+
),
54+
);
55+
if(Option.isNone(snapshot)){
5756
returnyield*failEnvironmentNotFound("thread_not_found");
5857
}
59-
return{ snapshotSequence,thread: threadDetail.value};
58+
returnsnapshot.value;
6059
}),
6160
)
6261
.handle(

‎apps/server/src/project/ProjectSetupScriptRunner.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
4343
getFullThreadDiffContext: ()=>Effect.die("unused"),
4444
getThreadShellById: ()=>Effect.die("unused"),
4545
getThreadDetailById: ()=>Effect.die("unused"),
46+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
4647
});
4748

4849
constmakeTerminalManagerLayer=(

‎apps/server/src/provider/Layers/ProviderSessionReaper.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe("ProviderSessionReaper", () => {
209209
: Option.none(),
210210
),
211211
getThreadDetailById: ()=>Effect.die("unused"),
212+
getThreadDetailSnapshot: ()=>Effect.die("unused"),
212213
}),
213214
),
214215
Layer.provideMerge(NodeServices.layer),

0 commit comments

Comments
 (0)