79 lines
3.7 KiB
JavaScript
79 lines
3.7 KiB
JavaScript
import axios from "axios";
|
|
import { createHash } from "crypto";
|
|
import { getPool } from "../db/index.js";
|
|
import { getConfig } from "../common/config.js";
|
|
import { firstNonBlankLine } from "./firstNonBlankLine.js";
|
|
|
|
// Delay (ms) before the next attempt, given how many have already failed.
|
|
// Exponential (startingDelay * factor^priorFailures), capped at maxDelay.
|
|
function backoffMs(priorFailures, { startingDelayMs, maxDelayMs, factor }) {
|
|
return Math.min(maxDelayMs, startingDelayMs * Math.pow(factor, priorFailures));
|
|
}
|
|
|
|
// Best-effort remote agreement fetch. A failure is logged, never thrown. Local
|
|
// URLs and already-cached agreements are skipped. Failures are tracked in
|
|
// agreement_fetch with exponential backoff so an invalid URL isn't re-fetched on
|
|
// every referencing create; after maxRetries we stop trying.
|
|
export async function cacheAgreementMarkdown({ userId = null, agreementUuid, uri }, _client) {
|
|
const config = getConfig();
|
|
if (typeof uri !== "string" || !uri || !agreementUuid) return;
|
|
if (uri.startsWith(config.publicCoreUrl)) return;
|
|
|
|
const client = _client || await getPool();
|
|
try {
|
|
const cached = await client.query(
|
|
`SELECT 1 FROM agreement_content WHERE agreement_uuid = $1 AND markdown IS NOT NULL`,
|
|
[agreementUuid],
|
|
);
|
|
if (cached.rowCount > 0) return;
|
|
|
|
const { maxRetries } = config.agreementCache;
|
|
const prior = await client.query(
|
|
`SELECT attempts, next_attempt_ts FROM agreement_fetch WHERE agreement_uuid = $1`,
|
|
[agreementUuid],
|
|
);
|
|
const attempts = prior.rows[0]?.attempts ?? 0;
|
|
if (attempts >= maxRetries) return; // gave up
|
|
if (prior.rowCount > 0 && new Date(prior.rows[0].next_attempt_ts) > new Date()) return; // not due yet
|
|
|
|
try {
|
|
const res = await axios.get(uri, { responseType: "text" });
|
|
const markdown = String(res.data ?? "").trim();
|
|
if (!markdown) throw new Error("empty response");
|
|
|
|
const title = firstNonBlankLine(markdown);
|
|
const hash = createHash("sha256").update(markdown).digest("hex");
|
|
// Content is immutable once inserted — INSERT only, never overwrite.
|
|
await client.query(
|
|
`INSERT INTO agreement_content (user_id, title, markdown, hash, agreement_uuid, remote_url)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT DO NOTHING`,
|
|
[userId, title, markdown, hash, agreementUuid, uri],
|
|
);
|
|
await client.query(`DELETE FROM agreement_fetch WHERE agreement_uuid = $1`, [agreementUuid]);
|
|
} catch (fetchErr) {
|
|
await recordFailure(client, agreementUuid, uri, attempts, fetchErr.message, config.agreementCache);
|
|
}
|
|
} catch (e) {
|
|
console.error(`cacheAgreementMarkdown(${agreementUuid}):`, e.message);
|
|
} finally {
|
|
if (!_client) await client.release();
|
|
}
|
|
}
|
|
|
|
// Bump the attempt counter and schedule the next try with exponential backoff.
|
|
async function recordFailure(client, agreementUuid, uri, priorFailures, message, cfg) {
|
|
const delayMs = Math.round(backoffMs(priorFailures, cfg));
|
|
await client.query(
|
|
`INSERT INTO agreement_fetch (agreement_uuid, remote_url, attempts, next_attempt_ts, last_error)
|
|
VALUES ($1, $2, $3, NOW() + ($4 || ' milliseconds')::interval, $5)
|
|
ON CONFLICT (agreement_uuid) DO UPDATE SET
|
|
attempts = $3,
|
|
next_attempt_ts = NOW() + ($4 || ' milliseconds')::interval,
|
|
last_error = $5,
|
|
remote_url = $2,
|
|
updated_ts = NOW()`,
|
|
[agreementUuid, uri, priorFailures + 1, String(delayMs), message],
|
|
);
|
|
}
|