Skip to content

Commit 58e228b

Browse files
committed
fix(channels): block claimed candidate lanes
1 parent e279265 commit 58e228b

2 files changed

Lines changed: 52 additions & 3 deletions

File tree

src/channels/message/ingress-queue.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,35 @@ describe("channel ingress queue", () => {
323323
});
324324
});
325325

326+
it("blocks lanes claimed by candidate rows before claiming later candidates", async () => {
327+
await withTempState(async (stateDir) => {
328+
let clock = 1;
329+
const queue = createChannelIngressQueue<{ lane: string }>({
330+
channelId: "test",
331+
accountId: "account",
332+
stateDir,
333+
now: () => clock++,
334+
});
335+
336+
await queue.enqueue("a", { lane: "chat-1" }, { receivedAt: 1 });
337+
await queue.enqueue("b", { lane: "chat-1" }, { receivedAt: 2 });
338+
await queue.enqueue("c", { lane: "chat-2" }, { receivedAt: 3 });
339+
await queue.claim("a", { ownerId: "sibling-worker" });
340+
341+
const claimed = await queue.claimNext({
342+
ownerId: "worker",
343+
candidateIds: ["a", "b", "c"],
344+
orderBy: "id",
345+
deriveLaneKey: (record) => record.payload.lane,
346+
});
347+
348+
expect(claimed?.id).toBe("c");
349+
expect(claimed?.laneKey).toBe("chat-2");
350+
const sameLanePending = (await queue.listPending()).find((record) => record.id === "b");
351+
expect(sameLanePending?.laneKey).toBeUndefined();
352+
});
353+
});
354+
326355
it("requires claim tokens before mutating claimed rows", async () => {
327356
await withTempState(async (stateDir) => {
328357
const queue = createChannelIngressQueue<{ text: string }>({

src/channels/message/ingress-queue.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,26 @@ export function createChannelIngressQueue<
475475
return runOpenClawStateWriteTransaction(
476476
(tx) => {
477477
const kysely = getChannelIngressKysely(tx.db);
478+
let effectiveBlocked = blocked;
479+
if (candidateIds && candidateIds.length > 0) {
480+
// Candidate snapshots can race a sibling drainer. If an earlier
481+
// candidate is now claimed, its lane must block later same-lane rows.
482+
const claimedCandidateRows = executeSqliteQuerySync(
483+
tx.db,
484+
kysely
485+
.selectFrom("channel_ingress_events")
486+
.selectAll()
487+
.where("queue_name", "=", queueName)
488+
.where("status", "=", "claimed")
489+
.where("event_id", "in", candidateIds),
490+
).rows;
491+
const claimedCandidateLaneKeys = claimedCandidateRows
492+
.map((row) => row.lane_key ?? claimOptions?.deriveLaneKey?.(baseRecord(row)))
493+
.filter((laneKey): laneKey is string => Boolean(laneKey));
494+
if (claimedCandidateLaneKeys.length > 0) {
495+
effectiveBlocked = new Set([...blocked, ...claimedCandidateLaneKeys]);
496+
}
497+
}
478498
const baseSelect = kysely
479499
.selectFrom("channel_ingress_events")
480500
.selectAll()
@@ -484,9 +504,9 @@ export function createChannelIngressQueue<
484504
if (candidateIds) {
485505
select = select.where("event_id", "in", candidateIds);
486506
}
487-
if (blocked.size > 0 && !claimOptions?.deriveLaneKey) {
507+
if (effectiveBlocked.size > 0 && !claimOptions?.deriveLaneKey) {
488508
select = select.where((eb) =>
489-
eb.or([eb("lane_key", "is", null), eb("lane_key", "not in", [...blocked])]),
509+
eb.or([eb("lane_key", "is", null), eb("lane_key", "not in", [...effectiveBlocked])]),
490510
);
491511
}
492512
let orderedSelect =
@@ -500,7 +520,7 @@ export function createChannelIngressQueue<
500520
const rows = executeSqliteQuerySync(tx.db, orderedSelect).rows;
501521
const selected = rows.find((row) => {
502522
const laneKey = row.lane_key ?? claimOptions?.deriveLaneKey?.(baseRecord(row));
503-
return !laneKey || !blocked.has(laneKey);
523+
return !laneKey || !effectiveBlocked.has(laneKey);
504524
});
505525
if (!selected) {
506526
return null;

0 commit comments

Comments
 (0)