Changeset 4ae254a in Klonkt for src


Ignore:
Timestamp:
10/26/2026 01:07:44 PM (27 hours ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
31288dc
Parents:
5d0d3f35
Message:

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@…>

Location:
src
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r5d0d3f35 r4ae254a  
    888888    );
    889889    CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
     890  `);
     891  // De bezorgwachtrij per SERVER en per INBOX (shaer-xzab.3). `host` zodat één
     892  // mislukking naar een server al zijn rijen opschuift, in plaats van dertig
     893  // rijen met elk acht seconden time-out op dezelfde dode server; de index op
     894  // (inbox, id) zodat "oudste eerst per inbox" geen volle scan per rij is.
     895  // Rijen van voor deze kolom krijgen hun host hier, eenmalig.
     896  ensureColumn('ap_delivery', 'host', 'TEXT');
     897  try {
     898    const zonder = db.prepare('SELECT id, inbox FROM ap_delivery WHERE host IS NULL').all();
     899    const zet = db.prepare('UPDATE ap_delivery SET host = ? WHERE id = ?');
     900    for (const r of zonder) {
     901      let h = null;
     902      try { h = new URL(r.inbox).host.toLowerCase(); } catch { /* onleesbaar: blijft null */ }
     903      zet.run(h, r.id);
     904    }
     905  } catch { /* een lege of ontbrekende wachtrij is geen fout */ }
     906  db.exec(`
     907    CREATE INDEX IF NOT EXISTS idx_ap_delivery_inbox ON ap_delivery(inbox, id);
     908    CREATE INDEX IF NOT EXISTS idx_ap_delivery_host ON ap_delivery(host);
    890909  `);
    891910  // "Feature" a followed account: its posts show in the local Cirkel. Heet in de
  • src/services/ap-transport.js

    r5d0d3f35 r4ae254a  
    193193// doesn't silently miss the post. The signing key is NOT stored — the worker
    194194// re-derives it from the actor slug at send time.
    195 const DELIVERY_MAX_ATTEMPTS = 6;
    196 const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
    197 let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
     195//
     196// KLONKT DIE NIET ALTIJD AAN STAAT (shaer-xzab). Een Klonkt op een Mac thuis
     197// slaapt, gaat de trein in, staat een weekend uit. Daarom:
     198//
     199//  - Een bezorging VERLOOPT OP LEEFTIJD (5 dagen sinds created_at), niet na een
     200//    aantal pogingen (xzab.1). Zes pogingen waren ~4,5 uur, en de laatste trede
     201//    van 360 minuten werd nooit gebruikt: de telling was er eerder.
     202//  - OFFLINE ZIJN KOST GEEN POGINGEN (xzab.2). Mislukt een hele beurt op
     203//    netwerkniveau, naar meer dan één server en zonder één HTTP-antwoord, dan
     204//    ligt het aan ons: alles een paar minuten opschuiven, de telling ongemoeid,
     205//    en niets laten verlopen. Slapen kost al niets, want dan loopt de timer niet.
     206//  - BACKOFF PER SERVER, VOLGORDE PER INBOX (xzab.3). Eén mislukking naar een
     207//    server schuift al zijn rijen op. En per inbox oudste eerst, ook bij het
     208//    directe versturen: bij een venster van dagen mag een herhaalde Create niet
     209//    NA de Delete van dezelfde post aankomen.
     210const DELIVERY_MAX_AGE_MS = 5 * 24 * 60 * 60 * 1000;
     211// Minuten tot de volgende poging, op de poging die net mislukte; de laatste
     212// trede herhaalt tot de bezorging verloopt.
     213const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360, 720, 1440];
     214// Hoe ver een beurt opschuift als WIJ offline blijken.
     215const OFFLINE_RETRY_MIN = 2;
     216
     217export function deliveryBackoffMinutes(attemptsSoFar) {
     218  return DELIVERY_BACKOFF_MIN[Math.min(Math.max(0, attemptsSoFar), DELIVERY_BACKOFF_MIN.length - 1)];
     219}
     220
     221/** De host (met poort) van een inbox, in kleine letters; null als het geen URL is. */
     222export function inboxHost(inbox) {
     223  try { return new URL(inbox).host.toLowerCase(); } catch { return null; }
     224}
     225
     226/** Is een bezorging zo oud dat hij opgegeven wordt? created_at is SQLite-UTC
     227 *  ('YYYY-MM-DD HH:MM:SS', zonder zone). */
     228export function deliveryExpired(createdAt, now = Date.now()) {
     229  if (!createdAt) return false;
     230  const t = Date.parse(String(createdAt).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(createdAt) ? '' : 'Z'));
     231  return Number.isFinite(t) && now - t >= DELIVERY_MAX_AGE_MS;
     232}
     233
     234/** Faalde dit op NETWERKNIVEAU (geen HTTP-antwoord)? Een SSRF-weigering is
     235 *  dat niet: die is een besluit, en blijft een besluit als we weer online zijn. */
     236function isNetworkError(e) {
     237  return !/^ssrf/.test(String((e && e.message) || ''));
     238}
     239
     240let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv, _hostDeliv, _offDeliv, _pendInbox, _hostWaits;
    198241function deliveryStmts() {
    199242  if (!_insDeliv) {
    200     _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
    201     _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
     243    _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, host, body, attempts, next_at) VALUES (?,?,?,?,0,CURRENT_TIMESTAMP)');
     244    // Alleen de OUDSTE openstaande rij per inbox: een nieuwere wacht op hem, ook
     245    // als hij zelf al aan de beurt zou zijn. Op id, want dat is de volgorde
     246    // waarin ze ontstonden.
     247    _dueDeliv = db.prepare(`SELECT d.* FROM ap_delivery d
     248      WHERE datetime(d.next_at) <= datetime('now')
     249        AND NOT EXISTS (SELECT 1 FROM ap_delivery o WHERE o.inbox = d.inbox AND o.id < d.id)
     250      ORDER BY d.id LIMIT 30`);
    202251    _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
    203252    _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
    204   }
    205   return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
     253    // Een server die faalde: al zijn rijen niet eerder dan de nieuwe poging.
     254    _hostDeliv = db.prepare('UPDATE ap_delivery SET next_at = ? WHERE host = ? AND datetime(next_at) < datetime(?)');
     255    // Wij offline: opschuiven, de telling ongemoeid.
     256    _offDeliv = db.prepare('UPDATE ap_delivery SET next_at = ? WHERE id = ?');
     257    _pendInbox = db.prepare('SELECT 1 FROM ap_delivery WHERE inbox = ? LIMIT 1');
     258    _hostWaits = db.prepare("SELECT 1 FROM ap_delivery WHERE host = ? AND datetime(next_at) > datetime('now') LIMIT 1");
     259  }
     260  return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv, host: _hostDeliv, off: _offDeliv, pendInbox: _pendInbox, hostWaits: _hostWaits };
    206261}
    207262export function enqueueDelivery(slug, inbox, activity) {
    208263  if (!slug || !inbox || !activity) return;
    209   try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
     264  try { deliveryStmts().ins.run(slug, inbox, inboxHost(inbox), JSON.stringify(activity)); } catch { /* ignore */ }
    210265}
    211266// Record delivery health per follower so the followers list can flag dead accounts.
    … …  
    224279}
    225280// Deliver now; queue for retry if it fails.
     281//
     282// Niet meteen als er voor deze inbox al iets in de wachtrij staat (dan zou dit
     283// er voorbij gaan: een Delete vóór de Create die nog wacht), en niet als deze
     284// server net faalde en in zijn backoff zit (dan kost het alleen acht seconden
     285// time-out om hetzelfde te horen). Dan achteraan in de rij.
    226286export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
    227287  if (!inbox) return;
     288  try {
     289    const st = deliveryStmts();
     290    if (st.pendInbox.get(inbox) || st.hostWaits.get(inboxHost(inbox))) { enqueueDelivery(slug, inbox, activity); return; }
     291  } catch { /* geen wachtrij te lezen: gewoon proberen */ }
    228292  try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) { markFollowerDelivery(slug, inbox, true); return; } } catch { /* queue below */ }
    229293  enqueueDelivery(slug, inbox, activity);
    … …  
    238302    if (!rows || !rows.length) return;
    239303    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     304    // Eerst alles proberen, pas daarna beslissen: of het aan ons lag, zie je
     305    // pas aan de hele beurt.
     306    const failed = [];          // { row, network }
     307    const down = new Set();     // servers die in deze beurt al faalden
     308    const skipped = [];         // rijen van zo'n server, niet meer geprobeerd
     309    let answered = false;       // kwam er ergens een HTTP-antwoord?
    240310    for (const row of rows) {
    241       let ok = false;
     311      const host = row.host || inboxHost(row.inbox);
     312      if (host && down.has(host)) { skipped.push(row); continue; }
     313      let ok = false, network = false;
    242314      try {
    243315        const keys = getOrCreateKeys(row.slug);
    244316        const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
     317        answered = true;
    245318        ok = st >= 200 && st < 300;
    246       } catch { ok = false; }
     319      } catch (e) { network = isNetworkError(e); }
    247320      if (ok) { markFollowerDelivery(row.slug, row.inbox, true); deliveryStmts().del.run(row.id); continue; }
    248       const attempts = row.attempts + 1;
    249       if (attempts >= DELIVERY_MAX_ATTEMPTS) { markFollowerDelivery(row.slug, row.inbox, false); deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
    250       // Index the backoff on the CURRENT attempt count (row.attempts) so the first
    251       // retry uses the 1-min tier instead of skipping it.
    252       const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];
    253       deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
     321      failed.push({ row, network, host });
     322      if (host) down.add(host);
     323    }
     324    if (!failed.length) return;
     325
     326    // Lag het aan ons? Niets beantwoord, alles op netwerkniveau, en naar meer
     327    // dan één server -- bij één server is "hij ligt eruit" net zo waarschijnlijk.
     328    const hosts = new Set(failed.map((f) => f.host));
     329    const offline = !answered && failed.every((f) => f.network) && hosts.size > 1;
     330    if (offline) {
     331      const later = new Date(Date.now() + OFFLINE_RETRY_MIN * 60000).toISOString();
     332      for (const r of [...failed.map((f) => f.row), ...skipped]) deliveryStmts().off.run(later, r.id);
     333      console.warn('[AP] delivery: no network for', failed.length, 'deliveries to', hosts.size, 'servers; retrying in', OFFLINE_RETRY_MIN, 'min, attempts untouched');
     334      return;
     335    }
     336
     337    for (const { row, host } of failed) {
     338      if (deliveryExpired(row.created_at)) {
     339        markFollowerDelivery(row.slug, row.inbox, false);
     340        deliveryStmts().del.run(row.id);
     341        console.warn('[AP] delivery gave up after', row.attempts + 1, 'tries over 5 days →', row.inbox);
     342        continue;
     343      }
     344      const next = new Date(Date.now() + deliveryBackoffMinutes(row.attempts) * 60000).toISOString();
     345      deliveryStmts().bump.run(row.attempts + 1, next, row.id);
     346      // De hele server wacht mee: zijn andere rijen niet eerder dan deze.
     347      if (host) deliveryStmts().host.run(next, host, next);
    254348    }
    255349  } finally { _processingDeliv = false; }
Note: See TracChangeset for help on using the changeset viewer.