The broker stores the highest comment id seen per business and task in an append-only table, task_comment_cursors. The tasks adapter restores those ids before its startup reconcile, and the first reconcile reads each task's comment list even with no cached count, so a comment posted while the bus was down is found. Cursor rows, snapshots and comment events commit in one transaction. migrate-schema.mjs takes a stopped store from 3b to 3c offline; the store refuses a 3b database until it runs. scripts/land-work/read.mjs reads SCHEMA_VERSION from store.mjs. The live bus is 3b. From this commit on, land-work refuses it with bus-schema until Mos migrates in the schema window (agents/sage/work/window-plan/schema-chain-3c-3e.md). Candidate 87ef1321a1a08be64a20499889fc24b5549b47d6d33abfd86db95f62fb3cb6d0, Rocko packet agents/rocko/work/queue-49/r1 (ea5e70fa8), base7b166a0f1. Filbert approved in 28579 and Darkwing in 28587. Landing gate atebbbf62da: 24 suites green, 0 failed. 14 package suites passed 1598 tests (bus 178, tasks 97, business 92, cli 195, webui 112). The 10 script suites passed (land-work 84, queue 156). Follow-ups: P19 tasks 300 (duplicate cursor) and 304 (push a task's cursor only when consider runs for it). Co-Authored-By: Claude Opus 5.5 <[email protected]>
101 lines
4.4 KiB
JavaScript
101 lines
4.4 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { world } from './world.mjs';
|
|
|
|
async function fixture(t) {
|
|
let now = Date.now();
|
|
const w = await world(t, { fake: { now: () => now }, adapter: { clock: () => now } });
|
|
await w.adapter.start('demo');
|
|
const owner = await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'watched' });
|
|
assert.equal(owner.status, 201);
|
|
await w.adapter.reconcile('demo');
|
|
assert.equal(w.store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
|
const events = () => w.events('task.changed.external').filter(e => e.body.comments);
|
|
return { w, events, advance: () => { now += 3 * 3600000; } };
|
|
}
|
|
|
|
test('stopped bus resumes an empty task cursor after a long outage, exactly once', async t => {
|
|
const { w, events, advance } = await fixture(t);
|
|
await w.stop();
|
|
advance();
|
|
assert.equal((await w.ui('POST', '/tasks/1/comments', { comment: 'while bus stopped' })).status, 201);
|
|
advance();
|
|
await w.restart();
|
|
assert.equal(events().length, 1);
|
|
assert.equal(events()[0].body.comments, 1);
|
|
await w.adapter.tick('demo');
|
|
await w.adapter.reconcile('demo');
|
|
assert.equal(events().length, 1);
|
|
});
|
|
|
|
test('persisted IDs avoid replay, exclude bots and bound rows to observed new comments', async t => {
|
|
const { w, events, advance } = await fixture(t);
|
|
await w.ui('POST', '/tasks/1/comments', { comment: 'before restart' });
|
|
await w.adapter.reconcile('demo');
|
|
assert.equal(events().length, 1);
|
|
const rows = () => w.store.get('SELECT count(*) n FROM task_comment_cursors').n;
|
|
assert.equal(rows(), 1);
|
|
await w.adapter.reconcile('demo');
|
|
await w.adapter.tick('demo');
|
|
assert.equal(rows(), 1);
|
|
await w.stop();
|
|
advance();
|
|
const r = await w.fake.fetch('http://vikunja.test/api/v2/tasks/1/comments', {
|
|
method: 'POST', headers: { authorization: 'Bearer ' + w.tokens.coder, 'content-type': 'application/json' },
|
|
body: JSON.stringify({ comment: 'bot while stopped' }),
|
|
});
|
|
assert.equal(r.status, 201);
|
|
await w.ui('POST', '/tasks/1/comments', { comment: 'human while stopped' });
|
|
advance();
|
|
await w.restart();
|
|
assert.equal(events().length, 2);
|
|
assert.equal(events()[1].body.comments, 1);
|
|
assert.equal(rows(), 2);
|
|
await w.stop();
|
|
await w.restart();
|
|
await w.adapter.tick('demo');
|
|
assert.equal(events().length, 2);
|
|
assert.equal(rows(), 2);
|
|
});
|
|
|
|
test('commit followed by crash before cache advance neither loses nor duplicates a comment', async t => {
|
|
const { w, events } = await fixture(t);
|
|
await w.ui('POST', '/tasks/1/comments', { comment: 'committed before crash' });
|
|
const record = w.broker.recordTask.bind(w.broker);
|
|
w.broker.recordTask = args => { record(args); throw Error('simulated crash after commit'); };
|
|
await assert.rejects(w.adapter.reconcile('demo'), /simulated crash/);
|
|
assert.equal(events().length, 1);
|
|
await w.stop();
|
|
await w.restart();
|
|
assert.equal(events().length, 1);
|
|
await w.adapter.tick('demo');
|
|
assert.equal(events().length, 1);
|
|
});
|
|
|
|
test('failed event insert rolls back cursor and a retry still emits the comment once', async t => {
|
|
const { w, events } = await fixture(t);
|
|
await w.ui('POST', '/tasks/1/comments', { comment: 'retry me' });
|
|
w.store.run("CREATE TRIGGER fixture_fail BEFORE INSERT ON events WHEN NEW.kind='task.changed.external' BEGIN SELECT RAISE(ABORT,'fixture failure'); END");
|
|
await assert.rejects(w.adapter.reconcile('demo'), /fixture failure/);
|
|
w.store.run('DROP TRIGGER fixture_fail');
|
|
await w.adapter.reconcile('demo');
|
|
assert.equal(events().length, 1);
|
|
assert.equal(events()[0].body.comments, 1);
|
|
});
|
|
|
|
// Removing the epoch margin loses a tracker comment thirty seconds behind host time.
|
|
test('initial cursor accepts a comment within the stated one-minute clock skew margin', async t => {
|
|
let trackerNow = Date.now() - 30000;
|
|
const skewed = await world(t, { fake: { now: () => trackerNow } });
|
|
await skewed.adapter.start('demo');
|
|
await skewed.ui('POST', `/projects/${skewed.project}/tasks`, { title: 'clock skew' });
|
|
await skewed.adapter.reconcile('demo');
|
|
await skewed.ui('POST', '/tasks/1/comments', { comment: 'thirty seconds behind' });
|
|
await skewed.adapter.reconcile('demo');
|
|
const found = skewed.events('task.changed.external').filter(e => e.body.comments);
|
|
assert.equal(found.length, 1);
|
|
assert.equal(found[0].body.comments, 1);
|
|
await skewed.adapter.reconcile('demo');
|
|
assert.equal(skewed.events('task.changed.external').filter(e => e.body.comments).length, 1);
|
|
});
|