Skip to content
83 changes: 66 additions & 17 deletions apps/server/src/orchestration-v2/EventSink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type OrchestrationV2Run,
OrchestrationV2DomainEvent,
type OrchestrationV2ProviderThread,
type OrchestrationV2TurnItem,
OrchestrationV2StoredEvent,
ProviderThreadId,
RunAttemptId,
Expand Down Expand Up @@ -135,6 +136,17 @@ export interface EventSinkV2Shape {
},
EventSinkV2Error
>;
/** Commits only if the stored turn item still equals expected. */
readonly writeIfTurnItemUnchanged: (input: {
readonly expected: OrchestrationV2TurnItem;
readonly events: ReadonlyArray<OrchestrationV2DomainEvent>;
}) => Effect.Effect<
{
readonly committed: boolean;
readonly storedEvents: ReadonlyArray<OrchestrationV2StoredEvent>;
},
EventSinkV2Error
>;
readonly commitCommand: (input: {
readonly commandId: CommandId;
readonly threadId: ThreadId;
Expand Down Expand Up @@ -580,31 +592,23 @@ const layerBase: Layer.Layer<
);
});

const writeIfProviderThreadUnchangedEffect = Effect.fn(
"orchestrationV2.EventSink.writeIfProviderThreadUnchanged",
)(function* (input: Parameters<EventSinkV2Shape["writeIfProviderThreadUnchanged"]>[0]) {
yield* Effect.annotateCurrentSpan({
"orchestration_v2.event_count": input.events.length,
"orchestration_v2.provider_thread_id": input.expected.id,
});
const expectedJson = yield* ProjectionStore.encodeProviderThreadPayload(input.expected);

return yield* commitThenPublish(
// Appends the events only if the stored payload still matches, in one transaction.
const writeIfStoredPayloadMatches = <E>(
readStoredPayload: Effect.Effect<ReadonlyArray<{ readonly payload_json: string }>, E>,
expectedJson: string,
events: ReadonlyArray<OrchestrationV2DomainEvent>,
) =>
commitThenPublish(
Effect.gen(function* () {
const rows = yield* sql<{ readonly payload_json: string }>`
SELECT payload_json
FROM orchestration_v2_projection_provider_threads
WHERE provider_thread_id = ${input.expected.id}
LIMIT 1
`;
const rows = yield* readStoredPayload;
if (rows[0]?.payload_json !== expectedJson) {
return {
committed: false as const,
storedEvents: [] as ReadonlyArray<OrchestrationV2StoredEvent>,
};
}

const normalized = yield* normalizeEvents(input.events);
const normalized = yield* normalizeEvents(events);
const storedEvents = yield* eventStore.append({ events: normalized });
const outcomes = yield* applyStoredEvents(storedEvents);
return { committed: true as const, storedEvents, outcomes };
Expand All @@ -614,6 +618,45 @@ const layerBase: Layer.Layer<
? publishStoredEvents(result.storedEvents, result.outcomes)
: Effect.void,
);

const writeIfProviderThreadUnchangedEffect = Effect.fn(
"orchestrationV2.EventSink.writeIfProviderThreadUnchanged",
)(function* (input: Parameters<EventSinkV2Shape["writeIfProviderThreadUnchanged"]>[0]) {
yield* Effect.annotateCurrentSpan({
"orchestration_v2.event_count": input.events.length,
"orchestration_v2.provider_thread_id": input.expected.id,
});
const expectedJson = yield* ProjectionStore.encodeProviderThreadPayload(input.expected);
return yield* writeIfStoredPayloadMatches(
sql<{ readonly payload_json: string }>`
SELECT payload_json
FROM orchestration_v2_projection_provider_threads
WHERE provider_thread_id = ${input.expected.id}
LIMIT 1
`,
expectedJson,
input.events,
);
});

const writeIfTurnItemUnchangedEffect = Effect.fn(
"orchestrationV2.EventSink.writeIfTurnItemUnchanged",
)(function* (input: Parameters<EventSinkV2Shape["writeIfTurnItemUnchanged"]>[0]) {
yield* Effect.annotateCurrentSpan({
"orchestration_v2.event_count": input.events.length,
"orchestration_v2.turn_item_id": input.expected.id,
});
const expectedJson = yield* ProjectionStore.encodeTurnItemPayload(input.expected);
return yield* writeIfStoredPayloadMatches(
sql<{ readonly payload_json: string }>`
SELECT payload_json
FROM orchestration_v2_projection_turn_items
WHERE turn_item_id = ${input.expected.id}
LIMIT 1
`,
expectedJson,
input.events,
);
});

const existingCommandResult =
Expand Down Expand Up @@ -928,6 +971,12 @@ const layerBase: Layer.Layer<
(cause) => new EventSinkWriteError({ eventCount: input.events.length, cause }),
),
),
writeIfTurnItemUnchanged: (input) =>
writeIfTurnItemUnchangedEffect(input).pipe(
Effect.mapError(
(cause) => new EventSinkWriteError({ eventCount: input.events.length, cause }),
),
),
commitCommand: (input) =>
commitCommandEffect(input).pipe(
Effect.mapError(
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,7 @@ const encodeMessagePayload = Schema.encodeEffect(
const encodePlanPayload = Schema.encodeEffect(
Schema.fromJsonString(OrchestrationV2PlanArtifactSchema),
);
const encodeTurnItemPayload = Schema.encodeEffect(
export const encodeTurnItemPayload = Schema.encodeEffect(
Schema.fromJsonString(OrchestrationV2TurnItemJsonSchema),
);
// The codec every *Json payload schema uses for timestamps, so index columns match payload_json.
Expand Down
Loading
Loading