b88251bc8b
* fix: self-healing claimNextMessage prevents stuck processing messages claimAndDelete → claimNextMessage with atomic self-healing: resets stale processing messages (>60s) back to pending before claiming. Eliminates stuck messages from generator crashes without external timers. Removes redundant idle-timeout reset in worker-service.ts. Adds QUEUE to logger Component type. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update stale comments in SessionQueueProcessor to reflect claim-confirm pattern Comments still referenced the old claim-and-delete pattern after the claimNextMessage rename. Updated to accurately describe the current lifecycle where messages are marked as processing and stay in DB until confirmProcessed() is called. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: move Date.now() inside transaction and extract stale threshold constant - Move Date.now() inside claimNextMessage transaction closure so timestamp is fresh if WAL contention causes retry - Extract STALE_PROCESSING_THRESHOLD_MS to module-level constant - Add comment clarifying strict < boundary semantics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
|
import { EventEmitter } from 'events';
|
|
import { SessionQueueProcessor, CreateIteratorOptions } from '../../../src/services/queue/SessionQueueProcessor.js';
|
|
import type { PendingMessageStore, PersistentPendingMessage } from '../../../src/services/sqlite/PendingMessageStore.js';
|
|
|
|
/**
|
|
* Mock PendingMessageStore that returns null (empty queue) by default.
|
|
* Individual tests can override claimNextMessage behavior.
|
|
*/
|
|
function createMockStore(): PendingMessageStore {
|
|
return {
|
|
claimNextMessage: mock(() => null),
|
|
toPendingMessage: mock((msg: PersistentPendingMessage) => ({
|
|
type: msg.message_type,
|
|
tool_name: msg.tool_name || undefined,
|
|
tool_input: msg.tool_input ? JSON.parse(msg.tool_input) : undefined,
|
|
tool_response: msg.tool_response ? JSON.parse(msg.tool_response) : undefined,
|
|
prompt_number: msg.prompt_number || undefined,
|
|
cwd: msg.cwd || undefined,
|
|
last_assistant_message: msg.last_assistant_message || undefined
|
|
}))
|
|
} as unknown as PendingMessageStore;
|
|
}
|
|
|
|
/**
|
|
* Create a mock PersistentPendingMessage for testing
|
|
*/
|
|
function createMockMessage(overrides: Partial<PersistentPendingMessage> = {}): PersistentPendingMessage {
|
|
return {
|
|
id: 1,
|
|
session_db_id: 123,
|
|
content_session_id: 'test-session',
|
|
message_type: 'observation',
|
|
tool_name: 'Read',
|
|
tool_input: JSON.stringify({ file: 'test.ts' }),
|
|
tool_response: JSON.stringify({ content: 'file contents' }),
|
|
cwd: '/test',
|
|
last_assistant_message: null,
|
|
prompt_number: 1,
|
|
status: 'pending',
|
|
retry_count: 0,
|
|
created_at_epoch: Date.now(),
|
|
started_processing_at_epoch: null,
|
|
completed_at_epoch: null,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
describe('SessionQueueProcessor', () => {
|
|
let store: PendingMessageStore;
|
|
let events: EventEmitter;
|
|
let processor: SessionQueueProcessor;
|
|
let abortController: AbortController;
|
|
|
|
beforeEach(() => {
|
|
store = createMockStore();
|
|
events = new EventEmitter();
|
|
processor = new SessionQueueProcessor(store, events);
|
|
abortController = new AbortController();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Ensure abort controller is triggered to clean up any pending iterators
|
|
abortController.abort();
|
|
// Remove all listeners to prevent memory leaks
|
|
events.removeAllListeners();
|
|
});
|
|
|
|
describe('createIterator', () => {
|
|
describe('idle timeout behavior', () => {
|
|
it('should exit after idle timeout when no messages arrive', async () => {
|
|
// Use a very short timeout for testing (50ms)
|
|
const SHORT_TIMEOUT_MS = 50;
|
|
|
|
// Mock the private waitForMessage to use short timeout
|
|
// We'll test with real timing but short durations
|
|
const onIdleTimeout = mock(() => {});
|
|
|
|
const options: CreateIteratorOptions = {
|
|
sessionDbId: 123,
|
|
signal: abortController.signal,
|
|
onIdleTimeout
|
|
};
|
|
|
|
const iterator = processor.createIterator(options);
|
|
|
|
// Store returns null (empty queue), so iterator waits for message event
|
|
// With no messages arriving, it should eventually timeout
|
|
|
|
const startTime = Date.now();
|
|
const results: any[] = [];
|
|
|
|
// We need to trigger the timeout scenario
|
|
// The iterator uses IDLE_TIMEOUT_MS (3 minutes) which is too long for tests
|
|
// Instead, we'll test the abort path and verify callback behavior
|
|
|
|
// Abort after a short delay to simulate timeout-like behavior
|
|
setTimeout(() => abortController.abort(), 100);
|
|
|
|
for await (const message of iterator) {
|
|
results.push(message);
|
|
}
|
|
|
|
// Iterator should exit cleanly when aborted
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
it('should invoke onIdleTimeout callback when idle timeout occurs', async () => {
|
|
// This test verifies the callback mechanism works
|
|
// We can't easily test the full 3-minute timeout, so we verify the wiring
|
|
|
|
const onIdleTimeout = mock(() => {
|
|
// Callback should trigger abort in real usage
|
|
abortController.abort();
|
|
});
|
|
|
|
const options: CreateIteratorOptions = {
|
|
sessionDbId: 123,
|
|
signal: abortController.signal,
|
|
onIdleTimeout
|
|
};
|
|
|
|
// To test this properly, we'd need to mock the internal waitForMessage
|
|
// For now, verify that abort signal exits cleanly
|
|
const iterator = processor.createIterator(options);
|
|
|
|
// Simulate external abort (which is what onIdleTimeout should do)
|
|
setTimeout(() => abortController.abort(), 50);
|
|
|
|
const results: any[] = [];
|
|
for await (const message of iterator) {
|
|
results.push(message);
|
|
}
|
|
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
it('should reset idle timer when message arrives', async () => {
|
|
const onIdleTimeout = mock(() => abortController.abort());
|
|
let callCount = 0;
|
|
|
|
// Return a message on first call, then null
|
|
(store.claimNextMessage as any) = mock(() => {
|
|
callCount++;
|
|
if (callCount === 1) {
|
|
return createMockMessage({ id: 1 });
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const options: CreateIteratorOptions = {
|
|
sessionDbId: 123,
|
|
signal: abortController.signal,
|
|
onIdleTimeout
|
|
};
|
|
|
|
const iterator = processor.createIterator(options);
|
|
const results: any[] = [];
|
|
|
|
// First message should be yielded
|
|
// Then queue is empty, wait for more
|
|
// Abort after receiving first message
|
|
setTimeout(() => abortController.abort(), 100);
|
|
|
|
for await (const message of iterator) {
|
|
results.push(message);
|
|
}
|
|
|
|
// Should have received exactly one message
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]._persistentId).toBe(1);
|
|
|
|
// Store's claimNextMessage should have been called at least twice
|
|
// (once returning message, once returning null)
|
|
expect(callCount).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe('abort signal handling', () => {
|
|
it('should exit immediately when abort signal is triggered', async () => {
|
|
const onIdleTimeout = mock(() => {});
|
|
|
|
const options: CreateIteratorOptions = {
|
|
sessionDbId: 123,
|
|
signal: abortController.signal,
|
|
onIdleTimeout
|
|
};
|
|
|
|
const iterator = processor.createIterator(options);
|
|
|
|
// Abort immediately
|
|
abortController.abort();
|
|
|
|
const results: any[] = [];
|
|
for await (const message of iterator) {
|
|
results.push(message);
|
|
}
|
|
|
|
// Should exit with no messages
|
|
expect(results).toHaveLength(0);
|
|
// onIdleTimeout should NOT be called when abort signal is used
|
|
expect(onIdleTimeout).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should take precedence over timeout when both could fire', async () => {
|
|
const onIdleTimeout = mock(() => {});
|
|
|
|
// Return null to trigger wait
|
|
(store.claimNextMessage as any) = mock(() => null);
|
|
|
|
const options: CreateIteratorOptions = {
|
|
sessionDbId: 123,
|
|
signal: abortController.signal,
|
|
onIdleTimeout
|
|
};
|
|
|
|
const iterator = processor.createIterator(options);
|
|
|
|
// Abort very quickly - before any timeout could fire
|
|
setTimeout(() => abortController.abort(), 10);
|
|
|
|
const results: any[] = [];
|
|
for await (const message of iterator) {
|
|
results.push(message);
|
|
}
|
|
|
|
// Should have exited cleanly
|
|
expect(results).toHaveLength(0);
|
|
// onIdleTimeout should NOT have been called
|
|
expect(onIdleTimeout).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('message event handling', () => {
|
|
it('should wake up when message event is emitted', async () => {
|
|
let callCount = 0;
|
|
const mockMessages = [
|
|
createMockMessage({ id: 1 }),
|
|
createMockMessage({ id: 2 })
|
|
];
|
|
|
|
// First call: return null (queue empty)
|
|
// After message event: return message
|
|
// Then return null again
|
|
(store.claimNextMessage as any) = mock(() => {
|
|
callCount++;
|
|
if (callCount === 1) {
|
|
// First check - queue empty, will wait
|
|
return null;
|
|
} else if (callCount === 2) {
|
|
// After wake-up - return message
|
|
return mockMessages[0];
|
|
} else if (callCount === 3) {
|
|
// Second check after message processed - empty again
|
|
return null;
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const options: CreateIteratorOptions = {
|
|
sessionDbId: 123,
|
|
signal: abortController.signal
|
|
};
|
|
|
|
const iterator = processor.createIterator(options);
|
|
const results: any[] = [];
|
|
|
|
// Emit message event after a short delay to wake up the iterator
|
|
setTimeout(() => events.emit('message'), 50);
|
|
|
|
// Abort after collecting results
|
|
setTimeout(() => abortController.abort(), 150);
|
|
|
|
for await (const message of iterator) {
|
|
results.push(message);
|
|
}
|
|
|
|
// Should have received exactly one message
|
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
if (results.length > 0) {
|
|
expect(results[0]._persistentId).toBe(1);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('event listener cleanup', () => {
|
|
it('should clean up event listeners on abort', async () => {
|
|
const options: CreateIteratorOptions = {
|
|
sessionDbId: 123,
|
|
signal: abortController.signal
|
|
};
|
|
|
|
const iterator = processor.createIterator(options);
|
|
|
|
// Get initial listener count
|
|
const initialListenerCount = events.listenerCount('message');
|
|
|
|
// Abort to trigger cleanup
|
|
abortController.abort();
|
|
|
|
// Consume the iterator
|
|
const results: any[] = [];
|
|
for await (const message of iterator) {
|
|
results.push(message);
|
|
}
|
|
|
|
// After iterator completes, listener count should be same or less
|
|
// (the cleanup happens inside waitForMessage which may not be called)
|
|
const finalListenerCount = events.listenerCount('message');
|
|
expect(finalListenerCount).toBeLessThanOrEqual(initialListenerCount + 1);
|
|
});
|
|
|
|
it('should clean up event listeners when message received', async () => {
|
|
// Return a message immediately
|
|
(store.claimNextMessage as any) = mock(() => createMockMessage({ id: 1 }));
|
|
|
|
const options: CreateIteratorOptions = {
|
|
sessionDbId: 123,
|
|
signal: abortController.signal
|
|
};
|
|
|
|
const iterator = processor.createIterator(options);
|
|
|
|
// Get first message
|
|
const firstResult = await iterator.next();
|
|
expect(firstResult.done).toBe(false);
|
|
expect(firstResult.value._persistentId).toBe(1);
|
|
|
|
// Now abort and complete iteration
|
|
abortController.abort();
|
|
|
|
// Drain remaining
|
|
for await (const _ of iterator) {
|
|
// Should not get here since we aborted
|
|
}
|
|
|
|
// Verify no leftover listeners (accounting for potential timing)
|
|
const finalListenerCount = events.listenerCount('message');
|
|
expect(finalListenerCount).toBeLessThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should continue after store error with backoff', async () => {
|
|
let callCount = 0;
|
|
|
|
(store.claimNextMessage as any) = mock(() => {
|
|
callCount++;
|
|
if (callCount === 1) {
|
|
throw new Error('Database error');
|
|
}
|
|
if (callCount === 2) {
|
|
return createMockMessage({ id: 1 });
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const options: CreateIteratorOptions = {
|
|
sessionDbId: 123,
|
|
signal: abortController.signal
|
|
};
|
|
|
|
const iterator = processor.createIterator(options);
|
|
const results: any[] = [];
|
|
|
|
// Abort after giving time for retry
|
|
setTimeout(() => abortController.abort(), 1500);
|
|
|
|
for await (const message of iterator) {
|
|
results.push(message);
|
|
break; // Exit after first message
|
|
}
|
|
|
|
// Should have recovered and received message after error
|
|
expect(results).toHaveLength(1);
|
|
expect(callCount).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('should exit cleanly if aborted during error backoff', async () => {
|
|
(store.claimNextMessage as any) = mock(() => {
|
|
throw new Error('Database error');
|
|
});
|
|
|
|
const options: CreateIteratorOptions = {
|
|
sessionDbId: 123,
|
|
signal: abortController.signal
|
|
};
|
|
|
|
const iterator = processor.createIterator(options);
|
|
|
|
// Abort during the backoff period
|
|
setTimeout(() => abortController.abort(), 100);
|
|
|
|
const results: any[] = [];
|
|
for await (const message of iterator) {
|
|
results.push(message);
|
|
}
|
|
|
|
// Should exit cleanly with no messages
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('message conversion', () => {
|
|
it('should convert PersistentPendingMessage to PendingMessageWithId', async () => {
|
|
const mockPersistentMessage = createMockMessage({
|
|
id: 42,
|
|
message_type: 'observation',
|
|
tool_name: 'Grep',
|
|
tool_input: JSON.stringify({ pattern: 'test' }),
|
|
tool_response: JSON.stringify({ matches: ['file.ts'] }),
|
|
prompt_number: 5,
|
|
created_at_epoch: 1704067200000
|
|
});
|
|
|
|
(store.claimNextMessage as any) = mock(() => mockPersistentMessage);
|
|
|
|
const options: CreateIteratorOptions = {
|
|
sessionDbId: 123,
|
|
signal: abortController.signal
|
|
};
|
|
|
|
const iterator = processor.createIterator(options);
|
|
const result = await iterator.next();
|
|
|
|
// Abort to clean up
|
|
abortController.abort();
|
|
|
|
expect(result.done).toBe(false);
|
|
expect(result.value).toMatchObject({
|
|
_persistentId: 42,
|
|
_originalTimestamp: 1704067200000,
|
|
type: 'observation',
|
|
tool_name: 'Grep',
|
|
prompt_number: 5
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|