Skip to content

Commit 21bd131

Browse files
sweetmantechclaude
andcommitted
refactor(chat-workflow): move active_stream_id CAS out of supabase lib
Per sweetman's review on updateChat.ts:64 — the active_stream_id-specific predicate logic doesn't belong in the Supabase plumbing. Restructured: - `lib/supabase/chats/updateChat.ts` now generic. The filter accepts `where: Partial<Tables<"chats">>` (a generic predicate that maps to `column = value` or `column IS NULL`) so no column name is hardcoded in the Supabase lib. - `lib/chat/compareAndSetChatActiveStreamId.ts` — new domain wrapper. Owns the "compare-and-set on active_stream_id" concept and returns a discriminated `{ok, claimed} | {ok: false, error}` result. Handler and reconcileExistingActiveStream both compose against this wrapper instead of constructing predicates inline. - Handler + reconcile updated to use the wrapper. Tests follow. 37/37 tests in touched files pass; full suite 2955/2955; lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cc65837 commit 21bd131

8 files changed

Lines changed: 209 additions & 130 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import{describe,it,expect,vi,beforeEach}from"vitest";
2+
import{compareAndSetChatActiveStreamId}from"@/lib/chat/compareAndSetChatActiveStreamId";
3+
import{updateChat}from"@/lib/supabase/chats/updateChat";
4+
5+
vi.mock("@/lib/supabase/chats/updateChat",()=>({
6+
updateChat: vi.fn(),
7+
}));
8+
9+
beforeEach(()=>vi.clearAllMocks());
10+
11+
describe("compareAndSetChatActiveStreamId",()=>{
12+
it("returns ok:true claimed:true when the row predicate matches and is updated",async()=>{
13+
vi.mocked(updateChat).mockResolvedValue({ok: true,rowsUpdated: 1,row: null});
14+
constresult=awaitcompareAndSetChatActiveStreamId("chat-1",null,"wrun_x");
15+
expect(result).toEqual({ok: true,claimed: true});
16+
expect(updateChat).toHaveBeenCalledWith(
17+
{id: "chat-1",where: {active_stream_id: null}},
18+
{active_stream_id: "wrun_x"},
19+
);
20+
});
21+
22+
it("returns ok:true claimed:false when the predicate matches no rows (race lost)",async()=>{
23+
vi.mocked(updateChat).mockResolvedValue({ok: true,rowsUpdated: 0,row: null});
24+
constresult=awaitcompareAndSetChatActiveStreamId("chat-1",null,"wrun_x");
25+
expect(result).toEqual({ok: true,claimed: false});
26+
});
27+
28+
it("returns ok:false with the underlying error on DB failure (distinct from race lost)",async()=>{
29+
vi.mocked(updateChat).mockResolvedValue({ok: false,error: "down"});
30+
constresult=awaitcompareAndSetChatActiveStreamId("chat-1",null,"wrun_x");
31+
expect(result).toEqual({ok: false,error: "down"});
32+
});
33+
34+
it("supports expecting a specific run id (placeholder → real promotion)",async()=>{
35+
vi.mocked(updateChat).mockResolvedValue({ok: true,rowsUpdated: 1,row: null});
36+
awaitcompareAndSetChatActiveStreamId("chat-1","pending-abc","wrun_real");
37+
expect(updateChat).toHaveBeenCalledWith(
38+
{id: "chat-1",where: {active_stream_id: "pending-abc"}},
39+
{active_stream_id: "wrun_real"},
40+
);
41+
});
42+
43+
it("supports next=null (releasing the slot)",async()=>{
44+
vi.mocked(updateChat).mockResolvedValue({ok: true,rowsUpdated: 1,row: null});
45+
awaitcompareAndSetChatActiveStreamId("chat-1","wrun_old",null);
46+
expect(updateChat).toHaveBeenCalledWith(
47+
{id: "chat-1",where: {active_stream_id: "wrun_old"}},
48+
{active_stream_id: null},
49+
);
50+
});
51+
});

‎lib/chat/__tests__/handleChatWorkflowStream.test.ts‎

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@ import { selectSessions } from "@/lib/supabase/sessions/selectSessions";
77
import{selectChats}from"@/lib/supabase/chats/selectChats";
88
import{isSandboxActive}from"@/lib/sandbox/isSandboxActive";
99
import{updateSession}from"@/lib/supabase/sessions/updateSession";
10-
import{updateChat}from"@/lib/supabase/chats/updateChat";
10+
import{compareAndSetChatActiveStreamId}from"@/lib/chat/compareAndSetChatActiveStreamId";
1111
import{maybeResumeChatStream}from"@/lib/chat/maybeResumeChatStream";
1212
import{persistLatestUserMessage}from"@/lib/chat/persistLatestUserMessage";
1313
import{start,getRun}from"workflow/api";
1414

1515
vi.mock("@/lib/chat/validateChatWorkflow",()=>({validateChatWorkflow: vi.fn()}));
1616
vi.mock("@/lib/supabase/sessions/selectSessions",()=>({selectSessions: vi.fn()}));
1717
vi.mock("@/lib/supabase/chats/selectChats",()=>({selectChats: vi.fn()}));
18-
vi.mock("@/lib/supabase/chats/updateChat",()=>({updateChat: vi.fn()}));
18+
vi.mock("@/lib/chat/compareAndSetChatActiveStreamId",()=>({
19+
compareAndSetChatActiveStreamId: vi.fn(),
20+
}));
1921
vi.mock("@/lib/sandbox/isSandboxActive",()=>({isSandboxActive: vi.fn()}));
2022
vi.mock("@/lib/supabase/sessions/updateSession",()=>({updateSession: vi.fn()}));
2123
vi.mock("@/lib/sandbox/buildActiveLifecycleUpdate",()=>({
@@ -186,34 +188,39 @@ describe("handleChatWorkflowStream", () => {
186188
});
187189

188190
it("returns 500 when the placeholder-CAS hits a DB error",async()=>{
189-
vi.mocked(updateChat).mockResolvedValueOnce({ok: false,error: "down"});
191+
vi.mocked(compareAndSetChatActiveStreamId).mockResolvedValueOnce({
192+
ok: false,
193+
error: "down",
194+
});
190195
constres=awaithandleChatWorkflowStream(makeRequest());
191196
expect(res.status).toBe(500);
192197
expect(start).not.toHaveBeenCalled();
193198
});
194199

195200
it("returns 409 (without calling start) when the placeholder-CAS loses the race",async()=>{
196-
vi.mocked(updateChat).mockResolvedValueOnce({ok: true,rowsUpdated: 0,row: null});
201+
vi.mocked(compareAndSetChatActiveStreamId).mockResolvedValueOnce({
202+
ok: true,
203+
claimed: false,
204+
});
197205
constres=awaithandleChatWorkflowStream(makeRequest());
198206
expect(res.status).toBe(409);
199207
expect(start).not.toHaveBeenCalled();
200208
});
201209

202210
it("starts the workflow only after placeholder CAS succeeds",async()=>{
203211
// First CAS = placeholder claim, second CAS = promote placeholder → real run id
204-
vi.mocked(updateChat)
205-
.mockResolvedValueOnce({ok: true,rowsUpdated: 1,row: null})
206-
.mockResolvedValueOnce({ok: true,rowsUpdated: 1,row: null});
212+
vi.mocked(compareAndSetChatActiveStreamId)
213+
.mockResolvedValueOnce({ok: true,claimed: true})
214+
.mockResolvedValueOnce({ok: true,claimed: true});
207215
mockStartedRun();
208216
constres=awaithandleChatWorkflowStream(makeRequest());
209217
expect(res.status).toBe(200);
210218
expect(start).toHaveBeenCalled();
211-
// Confirm CAS-before-start ordering
212-
constplaceholderCAS=vi.mocked(updateChat).mock.calls[0]?.[0];
213-
expect(placeholderCAS).toEqual({
214-
id: CHAT_ID,
215-
whereActiveStreamId: {equals: null},
216-
});
219+
// Confirm CAS-before-start ordering — first CAS pre-claims with expected=null
220+
constfirstCallArgs=vi.mocked(compareAndSetChatActiveStreamId).mock.calls[0];
221+
expect(firstCallArgs?.[0]).toBe(CHAT_ID);
222+
expect(firstCallArgs?.[1]).toBeNull();
223+
expect(firstCallArgs?.[2]).toMatch(/^pending-/);
217224
});
218225
});
219226

@@ -222,9 +229,9 @@ describe("handleChatWorkflowStream", () => {
222229
mockValidated();
223230
mockSessionOwnedActive();
224231
mockChatOwned();
225-
vi.mocked(updateChat)
226-
.mockResolvedValueOnce({ok: true,rowsUpdated: 1,row: null})
227-
.mockResolvedValueOnce({ok: true,rowsUpdated: 1,row: null});
232+
vi.mocked(compareAndSetChatActiveStreamId)
233+
.mockResolvedValueOnce({ok: true,claimed: true})
234+
.mockResolvedValueOnce({ok: true,claimed: true});
228235
});
229236

230237
it("returns 200 with text/event-stream and x-workflow-run-id",async()=>{
@@ -278,9 +285,9 @@ describe("handleChatWorkflowStream", () => {
278285
});
279286

280287
it("awaits cancel() and returns 409 if promote loses",async()=>{
281-
vi.mocked(updateChat)
282-
.mockResolvedValueOnce({ok: true,rowsUpdated: 1,row: null})// claim ok
283-
.mockResolvedValueOnce({ok: true,rowsUpdated: 0,row: null});// promote raced
288+
vi.mocked(compareAndSetChatActiveStreamId)
289+
.mockResolvedValueOnce({ok: true,claimed: true})// claim ok
290+
.mockResolvedValueOnce({ok: true,claimed: false});// promote raced
284291
constcancel=vi.fn(()=>Promise.resolve());
285292
vi.mocked(start).mockResolvedValue({
286293
runId: "wrun_lost",
@@ -294,9 +301,9 @@ describe("handleChatWorkflowStream", () => {
294301
});
295302

296303
it("still returns 409 if cancel() throws (best-effort)",async()=>{
297-
vi.mocked(updateChat)
298-
.mockResolvedValueOnce({ok: true,rowsUpdated: 1,row: null})
299-
.mockResolvedValueOnce({ok: true,rowsUpdated: 0,row: null});
304+
vi.mocked(compareAndSetChatActiveStreamId)
305+
.mockResolvedValueOnce({ok: true,claimed: true})
306+
.mockResolvedValueOnce({ok: true,claimed: false});
300307
vi.mocked(start).mockResolvedValue({
301308
runId: "wrun_lost",
302309
getReadable: ()=>newReadableStream(),

‎lib/chat/__tests__/reconcileExistingActiveStream.test.ts‎

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import{describe,it,expect,vi,beforeEach}from"vitest";
22
import{reconcileExistingActiveStream}from"@/lib/chat/reconcileExistingActiveStream";
33
import{getRun}from"workflow/api";
4-
import{updateChat}from"@/lib/supabase/chats/updateChat";
4+
import{compareAndSetChatActiveStreamId}from"@/lib/chat/compareAndSetChatActiveStreamId";
55

66
vi.mock("workflow/api",()=>({
77
getRun: vi.fn(),
88
}));
9-
vi.mock("@/lib/supabase/chats/updateChat",()=>({
10-
updateChat: vi.fn(),
9+
vi.mock("@/lib/chat/compareAndSetChatActiveStreamId",()=>({
10+
compareAndSetChatActiveStreamId: vi.fn(),
1111
}));
1212

1313
constCHAT_ID="chat-1";
@@ -41,13 +41,10 @@ describe("reconcileExistingActiveStream", () => {
4141

4242
it("returns action=ready after CASing a completed run's stale id to null",async()=>{
4343
mockRun("completed");
44-
vi.mocked(updateChat).mockResolvedValue({ok: true,rowsUpdated: 1,row: null});
44+
vi.mocked(compareAndSetChatActiveStreamId).mockResolvedValue({ok: true,claimed: true});
4545
constresult=awaitreconcileExistingActiveStream(CHAT_ID,RUN_ID);
4646
expect(result.action).toBe("ready");
47-
expect(updateChat).toHaveBeenCalledWith(
48-
{id: CHAT_ID,whereActiveStreamId: {equals: RUN_ID}},
49-
{active_stream_id: null},
50-
);
47+
expect(compareAndSetChatActiveStreamId).toHaveBeenCalledWith(CHAT_ID,RUN_ID,null);
5148
});
5249

5350
it("returns action=conflict when getRun throws (transient workflow API error)",async()=>{
@@ -57,7 +54,7 @@ describe("reconcileExistingActiveStream", () => {
5754
constresult=awaitreconcileExistingActiveStream(CHAT_ID,RUN_ID);
5855
expect(result.action).toBe("conflict");
5956
// Critical: we do NOT clear the stream id on transient error.
60-
expect(updateChat).not.toHaveBeenCalled();
57+
expect(compareAndSetChatActiveStreamId).not.toHaveBeenCalled();
6158
});
6259

6360
it("returns action=conflict when status promise rejects",async()=>{
@@ -75,19 +72,19 @@ describe("reconcileExistingActiveStream", () => {
7572
}asnever);
7673
constresult=awaitreconcileExistingActiveStream(CHAT_ID,RUN_ID);
7774
expect(result.action).toBe("conflict");
78-
expect(updateChat).not.toHaveBeenCalled();
75+
expect(compareAndSetChatActiveStreamId).not.toHaveBeenCalled();
7976
});
8077

81-
it("returns action=conflict when CAS-clear loses the race (rowsUpdated=0)",async()=>{
78+
it("returns action=conflict when CAS-clear loses the race (claimed=false)",async()=>{
8279
mockRun("completed");
83-
vi.mocked(updateChat).mockResolvedValue({ok: true,rowsUpdated: 0,row: null});
80+
vi.mocked(compareAndSetChatActiveStreamId).mockResolvedValue({ok: true,claimed: false});
8481
constresult=awaitreconcileExistingActiveStream(CHAT_ID,RUN_ID);
8582
expect(result.action).toBe("conflict");
8683
});
8784

8885
it("returns action=conflict when CAS-clear hits a DB error (ok:false)",async()=>{
8986
mockRun("completed");
90-
vi.mocked(updateChat).mockResolvedValue({ok: false,error: "down"});
87+
vi.mocked(compareAndSetChatActiveStreamId).mockResolvedValue({ok: false,error: "down"});
9188
constresult=awaitreconcileExistingActiveStream(CHAT_ID,RUN_ID);
9289
// P1 fix: a failed re-read after CAS no longer falls through to "ready".
9390
expect(result.action).toBe("conflict");
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import{updateChat}from"@/lib/supabase/chats/updateChat";
2+
3+
/**
4+
* Result of the CAS attempt. Forces callers to distinguish:
5+
*
6+
* - `{ ok: true, claimed: true }` — the row matched the expected value and
7+
* was updated to `next`.
8+
* - `{ ok: true, claimed: false }` — predicate didn't match (a race was
9+
* lost OR the row's `active_stream_id` is in some other state).
10+
* - `{ ok: false, error }` — Supabase / network failure. Distinct from
11+
* "race lost" so callers don't return a misleading 409 when the DB is
12+
* actually unhealthy.
13+
*/
14+
exporttypeCasChatActiveStreamIdResult=
15+
|{ok: true;claimed: boolean}
16+
|{ok: false;error: string};
17+
18+
/**
19+
* Atomically swap `chats.active_stream_id` from `expected` to `next` for
20+
* the given chat. Domain wrapper over the generic `updateChat` helper —
21+
* keeps the CAS-on-active_stream_id concept here (in the chat domain)
22+
* rather than in the Supabase plumbing.
23+
*
24+
* Used by `/api/chat/workflow` to:
25+
* - Claim the slot before `start(workflow)` (`expected: null`, `next: "pending-<uuid>"`).
26+
* - Promote the placeholder to the real run id after start.
27+
* - Release a stale slot in `reconcileExistingActiveStream`.
28+
*
29+
* @param chatId - Target chat id.
30+
* @param expected - The value `active_stream_id` must currently hold (null to
31+
* require an unset slot).
32+
* @param next - The value to write (null to release the slot).
33+
*/
34+
exportasyncfunctioncompareAndSetChatActiveStreamId(
35+
chatId: string,
36+
expected: string|null,
37+
next: string|null,
38+
): Promise<CasChatActiveStreamIdResult>{
39+
constresult=awaitupdateChat(
40+
{id: chatId,where: {active_stream_id: expected}},
41+
{active_stream_id: next},
42+
);
43+
44+
if(!result.ok){
45+
return{ok: false,error: result.error};
46+
}
47+
48+
return{ok: true,claimed: result.rowsUpdated>0};
49+
}

‎lib/chat/handleChatWorkflowStream.ts‎

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { validateChatWorkflow } from "@/lib/chat/validateChatWorkflow";
55
import{maybeResumeChatStream}from"@/lib/chat/maybeResumeChatStream";
66
import{selectSessions}from"@/lib/supabase/sessions/selectSessions";
77
import{selectChats}from"@/lib/supabase/chats/selectChats";
8-
import{updateChat}from"@/lib/supabase/chats/updateChat";
8+
import{compareAndSetChatActiveStreamId}from"@/lib/chat/compareAndSetChatActiveStreamId";
99
import{isSandboxActive}from"@/lib/sandbox/isSandboxActive";
1010
import{buildActiveLifecycleUpdate}from"@/lib/sandbox/buildActiveLifecycleUpdate";
1111
import{updateSession}from"@/lib/supabase/sessions/updateSession";
@@ -73,12 +73,9 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise<Re
7373
// the workflow. This closes the race where two requests both call start()
7474
// and bill the model before one loses the CAS.
7575
constplaceholder=`pending-${generateUUID()}`;
76-
constclaimed=awaitupdateChat(
77-
{id: validated.chatId,whereActiveStreamId: {equals: null}},
78-
{active_stream_id: placeholder},
79-
);
76+
constclaimed=awaitcompareAndSetChatActiveStreamId(validated.chatId,null,placeholder);
8077
if(!claimed.ok)returnerrorResponse("Internal server error",500);
81-
if(claimed.rowsUpdated===0){
78+
if(!claimed.claimed){
8279
returnerrorResponse("Another workflow is already running for this chat",409);
8380
}
8481

@@ -96,15 +93,11 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise<Re
9693
},
9794
]);
9895

99-
// Promote placeholder → real run id. We already own the slot so no CAS needed.
100-
constpromoted=awaitupdateChat(
101-
{id: validated.chatId,whereActiveStreamId: {equals: placeholder}},
102-
{active_stream_id: run.runId},
103-
);
104-
if(!promoted.ok||promoted.rowsUpdated===0){
105-
// Something asynchronously stole our slot, or DB went down between claim
106-
// and promote. Cancel the workflow we just started — losing the slot
107-
// means another stream owns the client.
96+
// Promote placeholder → real run id via CAS. If something asynchronously
97+
// stole the slot (or the DB went down) we cancel the workflow we just
98+
// started since another stream now owns the client.
99+
constpromoted=awaitcompareAndSetChatActiveStreamId(validated.chatId,placeholder,run.runId);
100+
if(!promoted.ok||!promoted.claimed){
108101
try{
109102
awaitgetRun(run.runId).cancel();
110103
}catch(error){

‎lib/chat/reconcileExistingActiveStream.ts‎

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import{getRun}from"workflow/api";
2-
import{updateChat}from"@/lib/supabase/chats/updateChat";
2+
import{compareAndSetChatActiveStreamId}from"@/lib/chat/compareAndSetChatActiveStreamId";
33

44
exporttypeReconcileResult=
55
|{action: "resume";runId: string;stream: ReadableStream<unknown>}
@@ -44,18 +44,13 @@ export async function reconcileExistingActiveStream(
4444
return{action: "conflict"};
4545
}
4646

47-
// Run is terminally done. Attempt to claim the slot for the new request by
48-
// CASing the stale id back to null. If we win → ready. Anything else
49-
// (race lost OR DB error) → conflict, so we never accidentally start a
50-
// duplicate workflow on the back of a failed read.
51-
constcleared=awaitupdateChat(
52-
{id: chatId,whereActiveStreamId: {equals: activeStreamId}},
53-
{active_stream_id: null},
54-
);
55-
56-
if(cleared.ok&&cleared.rowsUpdated>0){
47+
// Run is terminally done. Attempt to clear the stale id via CAS. If we
48+
// win → ready. Anything else (race lost OR DB error) → conflict, so we
49+
// never accidentally start a duplicate workflow on the back of a failed
50+
// read.
51+
constcleared=awaitcompareAndSetChatActiveStreamId(chatId,activeStreamId,null);
52+
if(cleared.ok&&cleared.claimed){
5753
return{action: "ready"};
5854
}
59-
6055
return{action: "conflict"};
6156
}

0 commit comments

Comments
 (0)