News post → town crier + Discord announcement pipeline

Replace the fire-and-forget Discord-only announce on publish with a
retry-safe, two-leg pipeline. When a post transitions into published-news
(false→true publish while in news, or category→news while published), an
announce_jobs row is enqueued with two INDEPENDENT delivery legs:

  • town crier — sidecar POST /towncrier via uoLinkClient (stable id
    `post-<id>` so a retry replaces rather than duplicates)
  • discord    — bot POST /internal/announce via botInternalClient
    (single source of truth for the #news channel stays in the bot)

An in-process poller (utils/announceWorker) sweeps the table every
ANNOUNCE_POLL_MS and dispatches each due leg with its own exponential
backoff (30s→2h, 6 attempts). A leg is retried on transient failures
(503/504/network) and failed fast on data/config errors (400 over-cap,
401/409). Publishing never blocks on the sidecar or Discord — enqueue is
local DB only. Parent `status` is a done/partial/failed rollup of the two
legs; posts.announced_at is stamped once both deliver.

Admin visibility: GET /admin/posts/:id/announce + a per-leg Retry
(POST .../announce/retry) surfaced in the PostEditor for news posts.

Pure decisions (text build/caps, classification, backoff, rollup) live in
announceJobs.logic and are unit-tested (server/test/announceJobs.test.js,
10 tests). The old manual /admin/uo-link/towncrier form is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
2026-07-11 16:25:25 -05:00
parent a2590812e0
commit 986a8d5d86
16 changed files with 790 additions and 35 deletions

View File

@@ -647,6 +647,37 @@ CREATE TABLE IF NOT EXISTS pages (
INDEX idx_pages_nav (show_in_nav, nav_group, nav_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Announcement pipeline. One row per publish event of a news post; the table
-- doubles as the job queue (a light in-process poller — utils/announceWorker.js
-- — sweeps it for due legs). Two INDEPENDENT delivery legs so a Discord outage
-- never blocks or retries the in-game town-crier leg and vice versa. `status` is
-- a derived rollup of the two legs (see announceJobs.logic.js): done when both
-- legs done, failed when both exhausted, partial in between. Each leg tracks its
-- own attempt count, last error, and next-due time for exponential backoff.
-- post_id is INT (matches posts.id) and cascades so deleting a post reaps its
-- jobs. posts.announce_job_id points back at the latest row for admin lookups.
CREATE TABLE IF NOT EXISTS announce_jobs (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
status ENUM('pending','partial','done','failed') NOT NULL DEFAULT 'pending',
towncrier_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
towncrier_attempts SMALLINT NOT NULL DEFAULT 0,
towncrier_last_error TEXT NULL,
towncrier_next_attempt_at DATETIME NULL,
discord_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
discord_attempts SMALLINT NOT NULL DEFAULT 0,
discord_last_error TEXT NULL,
discord_next_attempt_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
INDEX idx_announce_due (towncrier_status, towncrier_next_attempt_at),
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.
@@ -683,3 +714,13 @@ ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DE
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published_at DATETIME NULL;
ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, body);
-- News → town-crier + Discord announcement pipeline. Add the announcement-state
-- columns to posts on databases created before the pipeline landed. announced_at
-- is stamped once both legs deliver; announce_job_id points at the announce_jobs
-- row for the post's admin status panel. Kept as a plain column (not a hard FK)
-- so the idempotent boot migration never trips over a re-added constraint — the
-- pointer is resolved in application code and the CASCADE on announce_jobs.post_id
-- already keeps the two tables consistent.
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL;
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL;