Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions __tests__/unit/services/cat-worker-bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
6 changes: 6 additions & 0 deletions scripts/systemd/orangecat-cron@cat-account.timer
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions src/services/mentions/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,21 @@ export async function runCatMentions(
): Promise<MentionRunResult> {
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');
Expand Down
Loading