diff --git a/__tests__/unit/services/cat-worker-bootstrap.test.ts b/__tests__/unit/services/cat-worker-bootstrap.test.ts new file mode 100644 index 000000000..6ae7a6e30 --- /dev/null +++ b/__tests__/unit/services/cat-worker-bootstrap.test.ts @@ -0,0 +1,84 @@ +/** + * The Cat has to be able to come into existence. + * + * There is a circularity hiding in this feature, and the first version shipped + * into it: `resolveMentions` can only flag @cat when a Cat PROFILE exists, so + * with no account nothing is ever queued — and if the account were only + * established when something was queued, nothing ever would be. A freshly + * deployed platform would sit there with @cat resolving to nobody, looking + * exactly like a working feature nobody had used yet. + * + * So the worker establishes the account BEFORE it looks at the queue, and + * before the empty-queue exit. This test is the thing that stops that ordering + * from being "tidied up" later. + */ + +const ensureCatAccount = jest.fn(); +const claimCatMentions = jest.fn(); + +jest.mock('@/services/mentions/cat-account', () => ({ + ensureCatAccount: (...a: unknown[]) => ensureCatAccount(...a), +})); +jest.mock('@/services/mentions/queue', () => ({ + claimCatMentions: (...a: unknown[]) => claimCatMentions(...a), + completeCatMention: jest.fn(), + failCatMention: jest.fn(), + MAX_ATTEMPTS: 3, +})); +jest.mock('@/services/mentions/cat-reply', () => ({ + replyToConversationMention: jest.fn().mockResolvedValue(true), +})); + +import { runCatMentions } from '@/services/mentions/worker'; + +beforeEach(() => { + ensureCatAccount.mockReset().mockResolvedValue({ id: 'cat-1', username: 'cat' }); + claimCatMentions.mockReset().mockResolvedValue([]); +}); + +describe('the mention worker bootstraps the Cat', () => { + it('establishes the account even when the queue is empty', async () => { + await runCatMentions({} as never); + // The empty queue is the NORMAL state, and it is exactly the state a new + // deployment is in. Returning early without this call is the deadlock. + expect(ensureCatAccount).toHaveBeenCalledTimes(1); + }); + + it('establishes the account before it claims anything', async () => { + const order: string[] = []; + ensureCatAccount.mockImplementation(async () => { + order.push('ensure'); + return { id: 'cat-1', username: 'cat' }; + }); + claimCatMentions.mockImplementation(async () => { + order.push('claim'); + return []; + }); + + await runCatMentions({} as never); + expect(order).toEqual(['ensure', 'claim']); + }); + + it('still reports an empty run as empty', async () => { + await expect(runCatMentions({} as never)).resolves.toEqual({ + claimed: 0, + answered: 0, + failed: 0, + }); + }); + + it('answers a claimed mention once the account exists', async () => { + claimCatMentions.mockResolvedValue([ + { id: 'q1', source_type: 'message', source_id: 'm1', requester_id: 'u1', conversation_id: 'c1', parent_event_id: null, attempts: 1 }, + ]); + await expect(runCatMentions({} as never)).resolves.toMatchObject({ claimed: 1, answered: 1 }); + }); + + it('fails claimed mentions rather than speaking as nobody', async () => { + ensureCatAccount.mockResolvedValue(null); + claimCatMentions.mockResolvedValue([ + { id: 'q1', source_type: 'message', source_id: 'm1', requester_id: 'u1', conversation_id: 'c1', parent_event_id: null, attempts: 1 }, + ]); + await expect(runCatMentions({} as never)).resolves.toMatchObject({ failed: 1, answered: 0 }); + }); +}); diff --git a/scripts/systemd/orangecat-cron@cat-account.timer b/scripts/systemd/orangecat-cron@cat-account.timer index c7a481a15..59149a287 100644 --- a/scripts/systemd/orangecat-cron@cat-account.timer +++ b/scripts/systemd/orangecat-cron@cat-account.timer @@ -9,6 +9,12 @@ Description=OrangeCat Cat account invariant (daily) [Timer] +# OnActiveSec is what makes a freshly deployed Cat exist within the minute. +# `Persistent=true` only replays a run MISSED while the machine was down; a +# timer enabled for the first time simply waits for the next OnCalendar, which +# for a daily timer is up to 24 hours away. That gap is not cosmetic — until the +# account exists, @cat resolves to nobody and nothing can be queued. +OnActiveSec=1min OnCalendar=daily Persistent=true RandomizedDelaySec=15m diff --git a/src/services/mentions/worker.ts b/src/services/mentions/worker.ts index 698dac4ce..1c6e02b86 100644 --- a/src/services/mentions/worker.ts +++ b/src/services/mentions/worker.ts @@ -33,15 +33,21 @@ export async function runCatMentions( ): Promise { const result: MentionRunResult = { claimed: 0, answered: 0, failed: 0 }; + // BEFORE claiming, and before the empty-queue exit, because otherwise the + // system deadlocks on itself: resolveMentions can only flag @cat when a Cat + // profile exists, so with no account nothing ever queues — and if the account + // were only created when something was queued, nothing ever would be. The + // every-minute tick is therefore also what brings the Cat into existence. + // Cheap enough to do unconditionally: one primary-key lookup when it is a + // no-op, which is always after the first run. + const cat = await ensureCatAccount(admin); + const claimed = await claimCatMentions(admin, limit); result.claimed = claimed.length; if (claimed.length === 0) { return result; } - // Established once per tick rather than per mention: it is one indexed lookup - // when it is a no-op, and without it there is no sender to speak as. - const cat = await ensureCatAccount(admin); if (!cat) { for (const mention of claimed) { await failCatMention(admin, mention, 'no Cat account');