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

@@ -0,0 +1,68 @@
const { query } = require('../../utils/db')
const COLS =
'id, post_id, status, ' +
'towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at, ' +
'discord_status, discord_attempts, discord_last_error, discord_next_attempt_at, ' +
'created_at, updated_at'
// Whitelist so a `leg` value can be interpolated into a column name safely — it
// never comes from raw user input, but keep the guard explicit.
const LEGS = ['towncrier', 'discord']
function assertLeg(leg) {
if (!LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
}
async function create(postId) {
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
return res.insertId
}
async function findById(id) {
const rows = await query(`SELECT ${COLS} FROM announce_jobs WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function findByPostId(postId) {
const rows = await query(
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
[postId],
)
return rows[0] || null
}
// Jobs with at least one leg that is due now: pending and either never scheduled
// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time.
async function findDue(now = new Date(), limit = 25) {
return query(
`SELECT ${COLS} FROM announce_jobs
WHERE (towncrier_status = 'pending'
AND (towncrier_next_attempt_at IS NULL OR towncrier_next_attempt_at <= ?))
OR (discord_status = 'pending'
AND (discord_next_attempt_at IS NULL OR discord_next_attempt_at <= ?))
ORDER BY id ASC
LIMIT ?`,
[now, now, limit],
)
}
// Update one leg's columns. `fields` uses leg-agnostic keys (status, attempts,
// lastError, nextAttemptAt); we map them onto the leg-prefixed columns.
async function updateLeg(id, leg, { status, attempts, lastError, nextAttemptAt }) {
assertLeg(leg)
await query(
`UPDATE announce_jobs SET
${leg}_status = ?,
${leg}_attempts = ?,
${leg}_last_error = ?,
${leg}_next_attempt_at = ?
WHERE id = ?`,
[status, attempts, lastError ?? null, nextAttemptAt ?? null, id],
)
}
async function setStatus(id, status) {
await query('UPDATE announce_jobs SET status = ? WHERE id = ?', [status, id])
}
module.exports = { LEGS, create, findById, findByPostId, findDue, updateLeg, setStatus }

View File

@@ -0,0 +1,127 @@
// ── Announcement pipeline: pure logic ──────────────────────────────────────
//
// No DB, no network — just the decisions the worker and model make, kept here so
// they are unit-testable in isolation (server/test/announceJobs.test.js):
// • buildTownCrierText — turn a post into sidecar-safe town-crier lines
// • classifyTownCrier / classifyDiscord — map a dispatch result to done / retry
// / terminal, so a data problem fails fast and a transient outage retries
// • scheduleAfter — exponential backoff schedule + the attempt cap
// • rollupStatus — derive the parent job status from the two legs
const { deriveExcerpt } = require('../../utils/sanitizeHtml')
// Sidecar town-crier caps, mirrored from the admin route validation
// (admin.routes.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
// We pre-truncate to these so a published post never bounces with towncrier.error.
const MAX_LINES = 8
const MAX_LINE_LEN = 200
// Backoff between retries, indexed by attempts-so-far. Six attempts spread over
// ~a couple of hours; after the last one a leg is marked failed and surfaced in
// the post's admin panel. Shared by both legs.
const BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000]
const MAX_ATTEMPTS = BACKOFF_MS.length
// Trim to a hard length, appending an ellipsis only when something was cut.
function clamp(value, max) {
const s = String(value == null ? '' : value)
.replace(/\s+/g, ' ')
.trim()
if (s.length <= max) return s
return `${s.slice(0, max - 1).trimEnd()}`
}
// The public link that goes in the announcement. News has no per-post route
// (App.jsx only has the /site/news list), so we link the list — matches the
// pre-pipeline Discord announce behavior.
function articleUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/site/news`
}
// Build the town-crier lines: title, a one-line excerpt, then the URL. Each line
// is clamped to the sidecar's per-line cap and the whole thing to the line-count
// cap. Falls back to a stripped body excerpt when the post has no excerpt.
function buildTownCrierText(post, { baseUrl } = {}) {
const title = clamp(post.title, MAX_LINE_LEN)
const excerptSource = post.excerpt || deriveExcerpt(post.body, MAX_LINE_LEN) || ''
const lines = [title]
const excerpt = clamp(excerptSource, MAX_LINE_LEN)
if (excerpt) lines.push(excerpt)
const url = clamp(articleUrl(baseUrl), MAX_LINE_LEN)
if (url) lines.push(url)
return lines.filter(Boolean).slice(0, MAX_LINES)
}
// ── Result classification ──────────────────────────────────────────────────
// Both clients return { ok, status, error }. Map that to one of:
// done — delivered, mark the leg done
// retry — transient (shard restarting, bot down, network); back off + retry
// terminal — will never succeed as-is (over caps, bad auth/config); fail now
function classifyTownCrier(result) {
if (result && result.ok) return { outcome: 'done' }
const status = result ? result.status : 0
// 400 = over the line/duration caps (a data problem — do NOT retry).
// 401 = token mismatch, 409 = protocol mismatch (both config problems).
if (status === 400 || status === 401 || status === 409) {
return { outcome: 'terminal', error: legError(result) }
}
// 503 (shard not connected), 504 (shard timeout), 0 (network/timeout / not
// configured yet), and any other 5xx are transient — retry.
return { outcome: 'retry', error: legError(result) }
}
function classifyDiscord(result) {
if (result && result.ok) return { outcome: 'done' }
// The bot's /internal/announce collapses failures (503 = not connected,
// 400 = no news channel configured) without surfacing Discord's own
// retry_after, so there is no reliable terminal signal to key on here. Retry
// every failure on the shared backoff; a genuine config problem simply
// exhausts its attempts and lands as `failed` in the admin panel, where the
// per-leg retry button re-runs it after the channel is set.
return { outcome: 'retry', error: legError(result) }
}
function legError(result) {
if (!result) return 'no response'
if (result.status) {
return result.data && result.data.message
? `${result.status}: ${result.data.message}`
: result.error || `status ${result.status}`
}
return result.error || 'request failed'
}
// Given the number of attempts already made (>= 1), how long to wait before the
// next one — or null when the cap is reached and the leg should be failed.
function scheduleAfter(attempts) {
if (attempts >= MAX_ATTEMPTS) return null
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]
}
// Parent job status derived from the two leg statuses:
// done — both legs delivered
// failed — both legs gave up
// partial — at least one leg reached a terminal state while the other has not
// matched it (still pending/retrying, or the opposite terminal state)
// pending — neither leg is terminal yet
function rollupStatus(towncrierStatus, discordStatus) {
if (towncrierStatus === 'done' && discordStatus === 'done') return 'done'
if (towncrierStatus === 'failed' && discordStatus === 'failed') return 'failed'
const terminal = (s) => s === 'done' || s === 'failed'
if (terminal(towncrierStatus) || terminal(discordStatus)) return 'partial'
return 'pending'
}
module.exports = {
MAX_LINES,
MAX_LINE_LEN,
MAX_ATTEMPTS,
BACKOFF_MS,
buildTownCrierText,
articleUrl,
classifyTownCrier,
classifyDiscord,
scheduleAfter,
rollupStatus,
}

View File

@@ -0,0 +1,116 @@
// ── Announcement pipeline: orchestration ────────────────────────────────────
//
// Sits between the DB rows and the worker: creates jobs on publish, records each
// leg's outcome, keeps the parent `status` rollup in sync, stamps the post's
// announced_at when both legs land, and resets a leg for the admin retry button.
// The pure decisions (backoff, rollup, classification) live in .logic.js.
const db = require('./announceJobs.db')
const logic = require('./announceJobs.logic')
const posts = require('../posts/posts.model')
const log = require('../../utils/logger')('announce')
// Enqueue an announcement for a freshly-published news post: one job row (both
// legs pending, due immediately) plus a back-pointer on the post so the admin
// panel can find it. Returns the new job id.
async function enqueue(postId) {
const jobId = await db.create(postId)
await posts.linkAnnounceJob(postId, jobId)
log.info('announce job enqueued', { jobId, postId })
return jobId
}
// Should publishing this post fire the pipeline? Only on a real transition INTO
// "published news" — a false→true publish while in news, or a category change
// into news while already published — and never twice (guarded by the post's
// existing announce_job_id). Editing an already-announced post does not re-fire.
function shouldEnqueue(post, { wasPublished, wasNews }) {
if (!post || post.category !== 'news' || !post.published) return false
if (post.announce_job_id) return false
const wasNewsPublished = Boolean(wasPublished) && Boolean(wasNews)
return !wasNewsPublished
}
// Convenience used by the post controller: enqueue iff shouldEnqueue. Never
// throws — a pipeline hiccup must not break saving/publishing a post.
async function enqueueIfNeeded(post, transition) {
try {
if (!shouldEnqueue(post, transition)) return null
return await enqueue(post.id)
} catch (err) {
log.error('enqueueIfNeeded failed', { postId: post && post.id, message: err.message })
return null
}
}
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of
// logic.classify*'s results: 'done' | 'retry' | 'terminal'. For 'retry' we bump
// the attempt count and schedule the next run (or fail the leg once the cap is
// hit). Returns the updated job row.
async function recordOutcome(job, leg, { outcome, error }) {
const attempts = Number(job[`${leg}_attempts`]) || 0
if (outcome === 'done') {
await db.updateLeg(job.id, leg, { status: 'done', attempts, lastError: null, nextAttemptAt: null })
} else if (outcome === 'terminal') {
await db.updateLeg(job.id, leg, { status: 'failed', attempts: attempts + 1, lastError: error, nextAttemptAt: null })
log.warn('announce leg failed (terminal)', { jobId: job.id, leg, error })
} else {
const nextAttempts = attempts + 1
const delay = logic.scheduleAfter(nextAttempts)
if (delay === null) {
await db.updateLeg(job.id, leg, { status: 'failed', attempts: nextAttempts, lastError: error, nextAttemptAt: null })
log.warn('announce leg failed (retries exhausted)', { jobId: job.id, leg, attempts: nextAttempts, error })
} else {
const nextAttemptAt = new Date(Date.now() + delay)
await db.updateLeg(job.id, leg, { status: 'pending', attempts: nextAttempts, lastError: error, nextAttemptAt })
log.info('announce leg retry scheduled', { jobId: job.id, leg, attempts: nextAttempts, nextAttemptAt })
}
}
return refreshStatus(job.id)
}
// Recompute and persist the parent status from the two legs; stamp the post's
// announced_at the moment both legs have delivered.
async function refreshStatus(jobId) {
const job = await db.findById(jobId)
if (!job) return null
const status = logic.rollupStatus(job.towncrier_status, job.discord_status)
if (status !== job.status) await db.setStatus(jobId, status)
job.status = status
if (status === 'done') {
try {
await posts.markAnnounced(job.post_id)
} catch (err) {
log.warn('markAnnounced failed', { jobId, postId: job.post_id, message: err.message })
}
}
return job
}
// Admin retry button: reset one leg to pending, clear its error/backoff, and let
// the worker pick it up on the next tick. Resets the attempt count so a retry
// after a config fix gets a full budget again.
async function resetLeg(postId, leg) {
if (!db.LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
const job = await db.findByPostId(postId)
if (!job) return null
await db.updateLeg(job.id, leg, { status: 'pending', attempts: 0, lastError: null, nextAttemptAt: null })
log.info('announce leg reset for retry', { jobId: job.id, postId, leg })
return refreshStatus(job.id)
}
async function getByPostId(postId) {
return db.findByPostId(postId)
}
module.exports = {
enqueue,
shouldEnqueue,
enqueueIfNeeded,
recordOutcome,
refreshStatus,
resetLeg,
getByPostId,
}

View File

@@ -1,7 +1,7 @@
const { query } = require('../../utils/db')
const COLS =
'id, category, title, slug, excerpt, body, image_url, published, author_id, created_at, updated_at, published_at'
'id, category, title, slug, excerpt, body, image_url, published, author_id, created_at, updated_at, published_at, announced_at, announce_job_id'
// Published posts for a category, newest first — public feed.
async function listPublished(category) {

View File

@@ -78,6 +78,15 @@ async function setPublished(id, published) {
return postsDb.findById(id)
}
// Announcement pipeline back-pointers (see model/announceJobs).
async function linkAnnounceJob(id, jobId) {
await postsDb.update(id, { announce_job_id: jobId })
}
async function markAnnounced(id, at = new Date()) {
await postsDb.update(id, { announced_at: at })
}
async function remove(id) {
return postsDb.remove(id)
}
@@ -103,6 +112,8 @@ module.exports = {
create,
update,
setPublished,
linkAnnounceJob,
markAnnounced,
remove,
counts,
}