source: Klonkt/test/delivery-queue.test.js@ 7ccdced

main
Last change on this file since 7ccdced was 4ae254a, checked in by Robin <roboburr@…>, 29 hours ago

Bezorging voor een Klonkt die niet altijd aan staat: leeftijd, offline, per server

De uitgaande helft van shaer-xzab (Bart, 26-10): een Klonkt op een Mac thuis
slaapt, gaat de trein in, staat een weekend uit, en de bezorgwachtrij was daar
niet op gebouwd.

xzab.1 Verlopen op LEEFTIJD (5 dagen sinds created_at), niet na zes pogingen.
Zes pogingen waren ~4,5 uur, en de trede van 360 minuten werd nooit gebruikt:
de telling was er eerder. Treden nu 1m, 5m, 15m, 1u, 3u, 6u, 12u, dan elke 24u.

xzab.2 Offline zijn kost geen pogingen. Faalt een hele beurt op netwerkniveau
(geen enkel HTTP-antwoord) naar meer dan één server, dan ligt het aan ons:
alles twee minuten opschuiven, de telling ongemoeid, en niets laten verlopen.
Bij één server is "hij ligt eruit" net zo waarschijnlijk, dus dat telt gewoon,
en een SSRF-weigering is een besluit en geen netwerkfout.

xzab.3 Backoff per server, volgorde per inbox. Eén mislukking naar een server
schuift al zijn rijen op (nieuwe kolom ap_delivery.host, met een eenmalige
backfill), in plaats van dertig rijen met elk acht seconden time-out op
dezelfde dode server. Per inbox gaat de oudste eerst -- en ook het directe
versturen in deliverWithRetry wacht als er voor die inbox nog iets in de rij
staat, of als die server in zijn backoff zit. Anders kwam een Delete aan vóór
de Create die nog wachtte, en stond de post daarna weer op die server.

test/delivery-queue.test.js: twaalf toetsen tegen echte HTTP-servers en dichte
poorten. Twaalf mutaties apart gedraaid, elk gevangen; één toets bleek eerst
gemaskeerd (de backoff hield tegen wat de volgorde moest tegenhouden) en is
aangescherpt. Volledige suite 1362 groen.

Nog open in het epic: xzab.6 (wat zet bij Mastodon e.a. een domein terug op
beschikbaar), en daarna het wakker worden, xzab.4 en xzab.5.

Niet in deze ronde: drie plekken versturen zelf direct en zetten pas bij een
mislukking in de rij (guardianship/delivery.js, ActivityPubService bij een
antwoord en bij het wissen van een reactie). Die gaan niet langs de volgorde
per inbox.

Co-Authored-By: Claude Opus 5.5 <noreply@…>

  • Property mode set to 100644
File size: 9.5 KB
RevLine 
[4ae254a]1// De bezorgwachtrij voor een Klonkt die niet altijd aan staat (shaer-xzab).
2//
3// Wat hier bewaakt wordt, elk met zijn eigen toets:
4// .1 een bezorging verloopt op LEEFTIJD (5 dagen), niet na zes pogingen, en
5// de treden lopen door tot een dag;
6// .2 offline zijn kost geen pogingen: een beurt die naar meer dan één server
7// op netwerkniveau faalt, schuift op zonder te tellen en laat niets
8// verlopen -- maar een SSRF-weigering of één dode server telt gewoon;
9// .3 één mislukking naar een server schuift al zijn rijen op, en per inbox
10// gaat de oudste eerst, ook bij het directe versturen.
11//
12// Tegen echte HTTP-servers op 127.0.0.1, toegelaten via AP_ALLOW_HOSTS, en twee
13// poorten waar niets luistert als "dode server".
14import { test } from 'node:test';
15import assert from 'node:assert/strict';
16import http from 'http';
17
18process.env.DATABASE_PATH = ':memory:';
19process.env.PUBLIC_BASE_URL = 'https://test.example';
20
21// Twee inboxen die antwoorden, en wat ze kregen.
22const ontvangen = []; // { host, type, id }
23const status = new Map(); // host -> HTTP-status (standaard 202)
24function inbox() {
25 const s = http.createServer((req, res) => {
26 let body = '';
27 req.on('data', (c) => { body += c; });
28 req.on('end', () => {
29 const host = req.headers.host;
30 try { const a = JSON.parse(body); ontvangen.push({ host, type: a.type, id: a.id }); } catch { /* geen json */ }
31 res.statusCode = status.get(host) || 202; res.end();
32 });
33 });
34 return new Promise((r) => s.listen(0, '127.0.0.1', () => r(s)));
35}
36async function dichtePoort() {
37 const s = http.createServer();
38 await new Promise((r) => s.listen(0, '127.0.0.1', r));
39 const p = s.address().port;
40 await new Promise((r) => s.close(r));
41 return p;
42}
43const A = await inbox();
44const B = await inbox();
45const hostA = `127.0.0.1:${A.address().port}`;
46const hostB = `127.0.0.1:${B.address().port}`;
47const dood1 = `127.0.0.1:${await dichtePoort()}`;
48const dood2 = `127.0.0.1:${await dichtePoort()}`;
49process.env.AP_ALLOW_HOSTS = [hostA, hostB, dood1, dood2].join(',');
50test.after(() => { A.close(); B.close(); });
51
52const dbMod = await import('../src/config/database.js');
53const db = dbMod.default;
54{ const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
55const T = await import('../src/services/ap-transport.js');
56const stilWarn = (f) => async () => { const w = console.warn; console.warn = () => {}; try { await f(); } finally { console.warn = w; } };
57
58const url = (host, pad = '/inbox') => `http://${host}${pad}`;
59const leeg = () => { db.prepare('DELETE FROM ap_delivery').run(); ontvangen.length = 0; status.clear(); };
60const rij = (id) => db.prepare('SELECT * FROM ap_delivery WHERE id = ?').get(id);
61const alle = () => db.prepare('SELECT * FROM ap_delivery ORDER BY id').all();
62function zet(inboxUrl, activity, { dagenOud = 0, attempts = 0, over = 0 } = {}) {
63 T.enqueueDelivery('kind', inboxUrl, activity);
64 const id = db.prepare('SELECT max(id) id FROM ap_delivery').get().id;
65 db.prepare(`UPDATE ap_delivery SET attempts = ?,
66 created_at = datetime('now', ?), next_at = datetime('now', ?) WHERE id = ?`)
67 .run(attempts, `-${dagenOud * 24 * 60} minutes`, `${over >= 0 ? '+' : ''}${over} minutes`, id);
68 return id;
69}
70const minutenTot = (iso) => (Date.parse(String(iso).replace(' ', 'T') + (/Z$/.test(iso) ? '' : 'Z')) - Date.now()) / 60000;
71
72// ── .1 Verlopen op leeftijd ──────────────────────────────────────
73
74test('de treden lopen door tot een dag, en de laatste herhaalt', () => {
75 assert.deepEqual([0, 1, 2, 3, 4, 5, 6, 7, 30].map(T.deliveryBackoffMinutes), [1, 5, 15, 60, 180, 360, 720, 1440, 1440]);
76});
77
78test('zes pogingen is geen reden meer om op te geven; vijf dagen wel', stilWarn(async () => {
79 leeg();
80 status.set(hostA, 500);
81 const jong = zet(url(hostA, '/jong'), { type: 'Create', id: 'n1' }, { dagenOud: 4, attempts: 6 });
82 await T.processDeliveryQueue();
83 assert.ok(rij(jong), 'vier dagen oud met zes pogingen: blijft staan');
84 assert.equal(rij(jong).attempts, 7);
85 assert.ok(Math.abs(minutenTot(rij(jong).next_at) - 720) < 2, 'en de trede van twaalf uur wordt gebruikt');
86
87 leeg();
88 status.set(hostA, 500);
89 const oud = zet(url(hostA, '/oud'), { type: 'Create', id: 'n2' }, { dagenOud: 6, attempts: 2 });
90 await T.processDeliveryQueue();
91 assert.equal(rij(oud), undefined, 'zes dagen oud: opgegeven');
92}));
93
94test('de leeftijd leest SQLite-UTC zoals hij is', () => {
95 const nu = Date.parse('2026-10-26T12:00:00Z');
96 assert.equal(T.deliveryExpired('2026-10-21 12:00:01', nu), false);
97 assert.equal(T.deliveryExpired('2026-10-21 12:00:00', nu), true);
98});
99
100// ── .2 Offline kost niets ────────────────────────────────────────
101
102test('geen netwerk naar twee servers: opschuiven, niet tellen, niet verlopen', stilWarn(async () => {
103 leeg();
104 const een = zet(url(dood1), { type: 'Create', id: 'n3' }, { attempts: 3, dagenOud: 6 });
105 const twee = zet(url(dood2), { type: 'Create', id: 'n4' }, { attempts: 1 });
106 await T.processDeliveryQueue();
107 assert.ok(rij(een) && rij(twee), 'niets opgegeven, ook niet wat oud was');
108 assert.equal(rij(een).attempts, 3);
109 assert.equal(rij(twee).attempts, 1);
110 assert.ok(Math.abs(minutenTot(rij(een).next_at) - 2) < 1, 'een paar minuten opgeschoven');
111}));
112
113test('één dode server is gewoon een dode server, en telt', stilWarn(async () => {
114 leeg();
115 const id = zet(url(dood1), { type: 'Create', id: 'n5' }, { attempts: 2 });
116 await T.processDeliveryQueue();
117 assert.equal(rij(id).attempts, 3);
118}));
119
120test('een SSRF-weigering is een besluit, geen offline', stilWarn(async () => {
121 leeg();
122 const een = zet('http://127.0.0.2:9/inbox', { type: 'Create', id: 'n6' });
123 const twee = zet('http://127.0.0.3:9/inbox', { type: 'Create', id: 'n7' });
124 await T.processDeliveryQueue();
125 assert.equal(rij(een).attempts, 1, 'geteld, ook al antwoordde niemand');
126 assert.equal(rij(twee).attempts, 1);
127}));
128
129// ── .3 Per server en per inbox ───────────────────────────────────
130
131test('één mislukking naar een server schuift al zijn rijen op', stilWarn(async () => {
132 leeg();
133 status.set(hostA, 503);
134 const ids = ['/x', '/y', '/z'].map((p, i) => zet(url(hostA, p), { type: 'Create', id: `h${i}` }));
135 const elders = zet(url(hostB), { type: 'Create', id: 'b1' });
136 await T.processDeliveryQueue();
137 assert.equal(ontvangen.filter((o) => o.host === hostA).length, 1, 'één verzoek naar de dode server, niet drie');
138 const eerste = rij(ids[0]);
139 for (const id of ids) assert.ok(minutenTot(rij(id).next_at) >= minutenTot(eerste.next_at) - 0.1, 'allemaal niet eerder dan de mislukte');
140 assert.deepEqual(ids.slice(1).map((id) => rij(id).attempts), [0, 0], 'wat niet geprobeerd is, telt niet');
141 assert.equal(rij(elders), undefined, 'een andere server gaat gewoon door');
142}));
143
144test('per inbox de oudste eerst: geen Delete voor de Create die nog wacht', async () => {
145 leeg();
146 const create = zet(url(hostA), { type: 'Create', id: 'p1' }, { over: 30 }); // wacht nog
147 zet(url(hostA), { type: 'Delete', id: 'p1-del' }); // al aan de beurt
148 await T.processDeliveryQueue();
149 assert.deepEqual(ontvangen, [], 'de Delete wacht op de Create');
150 db.prepare("UPDATE ap_delivery SET next_at = datetime('now', '-1 minutes') WHERE id = ?").run(create);
151 await T.processDeliveryQueue();
152 await T.processDeliveryQueue();
153 assert.deepEqual(ontvangen.map((o) => o.type), ['Create', 'Delete']);
154 assert.deepEqual(alle(), []);
155});
156
157test('direct versturen gaat niet voorbij wat er voor die inbox wacht', async () => {
158 leeg();
159 const { private_pem } = T.getOrCreateKeys('kind');
160 // De Create is AL aan de beurt, alleen nog niet opgepakt: de server zit dus
161 // niet in een backoff, en alleen de volgorde per inbox houdt de Delete tegen.
162 zet(url(hostA), { type: 'Create', id: 'q1' }, { over: -1 });
163 await T.deliverWithRetry('kind', url(hostA), { type: 'Delete', id: 'q1-del' }, 'https://test.example/ap/users/kind#main-key', private_pem);
164 assert.deepEqual(ontvangen, [], 'niet verstuurd');
165 assert.deepEqual(alle().map((r) => JSON.parse(r.body).type), ['Create', 'Delete'], 'maar achteraan in de rij');
166});
167
168test('en niet naar een server die in zijn backoff zit', async () => {
169 leeg();
170 const { private_pem } = T.getOrCreateKeys('kind');
171 zet(url(hostA, '/ander'), { type: 'Create', id: 'r1' }, { over: 30 });
172 await T.deliverWithRetry('kind', url(hostA, '/nog-een'), { type: 'Create', id: 'r2' }, 'https://test.example/ap/users/kind#main-key', private_pem);
173 assert.deepEqual(ontvangen, [], 'geen acht seconden time-out om hetzelfde te horen');
174 assert.equal(alle().length, 2);
175});
176
177test('zonder wachtrij gaat direct versturen gewoon direct', async () => {
178 leeg();
179 const { private_pem } = T.getOrCreateKeys('kind');
180 await T.deliverWithRetry('kind', url(hostB), { type: 'Create', id: 's1' }, 'https://test.example/ap/users/kind#main-key', private_pem);
181 assert.deepEqual(ontvangen.map((o) => o.id), ['s1']);
182 assert.deepEqual(alle(), []);
183});
184
185test('rijen van voor de host-kolom krijgen hun host bij het opstarten', () => {
186 leeg();
187 db.prepare("INSERT INTO ap_delivery (slug, inbox, body) VALUES ('kind', ?, '{}')").run(url(hostB, '/oud'));
188 db.prepare('UPDATE ap_delivery SET host = NULL').run();
189 { const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
190 assert.equal(alle()[0].host, hostB);
191});
Note: See TracBrowser for help on using the repository browser.