All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m45s
CD / Build and push images (push) Successful in 3m49s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m35s
CI / Import/export fidelity gate (push) Successful in 47s
Off-host backups for every self-hoster, configured entirely in the admin UI — supersedes the host-specific mirror plan behind #84. shared: - webdav.ts (new package entry like token-crypto): minimal WebDAV client with basic auth — PROPFIND (tolerant multistatus parser), MKCOL, PUT (streamed), GET, DELETE; Nextcloud DAV path derived from the plain server URL, explicit DAV bases pass through - backup-status.ts: additive remote-upload status in status.json, the restore-status.json contract (running/succeeded/failed + staleness bound), the backup_command/backup_maintenance NOTIFY channels, and the one-bundle-per-set naming (dorfteich-backup-<id>.tar.gz) - backup-set.ts moved here from apps/backup (api lists local sets) backup sidecar: - reads the backup.* instance settings directly from the database (admin changes apply next run; local retention row overrides the env) and the app password from the secret store - after each successful set: bundle dump + files archive + manifest into ONE self-contained tar.gz, upload via WebDAV per schedule (off/daily/weekly; manual runs always upload), prune remote bundles — never the newest — and record the outcome in status.json; upload failures alert via a new backupUploadFailed mail (de+en) - command listener on backup_command (run / restore) with a serial queue against the nightly timer - restore orchestrator: restore-status.json → maintenance NOTIFY → grace → (remote: download + manifest-verify bundle) → terminate other DB connections → shared perform-restore path (same code as restore.sh) → final status + maintenance exit api: - MaintenanceGuard (global, registered before the setup gate): 503 maintenance_mode while restore-status says running; health endpoints and the new public GET /backup/restore-status stay exempt; a stale running state (crashed sidecar) unblocks after 30 min - MaintenanceStateService watches the file and restarts the api after a successful restore (fresh caches, migrate-on-start for older dumps); main.ts refuses to touch the database while a restore runs — a container restarting mid-restore must not race pg_restore with migrate deploy - worker sweeps (conversion, mail outbox, scheduler) catch transient database failures instead of dying on an unhandled rejection — the restore's connection termination crashed the api in verification - backup admin endpoints under /admin/system/backup: settings (live connection test before save, password write-only into the secret store), nextcloud/test, sets (local via the ro backups mount + remote via WebDAV), run + restore (type-to-confirm backstop, source validation) — commands travel as NOTIFY payloads; audit actions backup.settings_changed/run_triggered/restore_requested - readyz: new warning-level backup_remote check while a target is configured (26 h daily / 170 h weekly bound) collab: - maintenance listener: on enter, persist + close every live session and refuse new connections until exit (failsafe timeout 30 min) — no in-memory document may write pre-restore content back afterwards web: - Admin → System backup section: status card with remote facts and a "Back up now" button, the Nextcloud settings form with test button, and the restore picker (local + remote sets, type-to-confirm) - global maintenance screen: any 503 maintenance_mode flips the SPA to a status page polling the exempt endpoint, reloading when the instance returns Verified end-to-end against a live stack (fresh DB, native api + sidecar, fake WebDAV server): configure → test → manual backup → bundle upload → readyz/sets/status surfaces → remote restore with maintenance gate, marker rollback and api restart; suites: shared 21, backup 9, collab 11, api 58 files green, lint + i18n:check + typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
209 lines
8.2 KiB
TypeScript
209 lines
8.2 KiB
TypeScript
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
|
import { ModuleRef } from '@nestjs/core';
|
|
import { ConversionJob } from '@prisma/client';
|
|
import { PinoLogger } from 'nestjs-pino';
|
|
|
|
import { AppConfig } from '../config/app-config.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
import {
|
|
DATA_EXPORT_KIND,
|
|
DATA_EXPORT_PROCESSOR,
|
|
DATA_EXPORT_TTL_MS,
|
|
DataExportProcessor,
|
|
} from './data-export.constants';
|
|
import { GotenbergRenderer, RenderError } from './gotenberg.renderer';
|
|
import { IMPORT_PROCESSOR, ImportProcessor, isImportKind } from './import.constants';
|
|
import { ConversionError, ConversionResult, PandocConverter } from './pandoc.converter';
|
|
|
|
/** How often the worker sweeps for pending jobs on its own — the safety net
|
|
* that makes a queued conversion survive an API restart even if no new
|
|
* enqueue wakes it. Enqueues also wake it immediately (interactive latency). */
|
|
const SWEEP_MS = 2000;
|
|
/** A job left RUNNING past this (crashed worker, never released) is treated as
|
|
* available again — comfortably longer than the 60 s conversion timeout. */
|
|
const STALE_LOCK_MS = 5 * 60_000;
|
|
/** Transient failures (sidecar down / timed out) are retried up to this many
|
|
* total attempts before the job is marked failed. */
|
|
const MAX_ATTEMPTS = 3;
|
|
|
|
/**
|
|
* Drains the conversion job queue (ADR 0009, issue #62). Claims one PENDING
|
|
* job at a time with `FOR UPDATE SKIP LOCKED` (safe against a second worker
|
|
* and against its own overlapping sweeps), calls the pandoc sidecar with a
|
|
* timeout, and stores the output or a localizable error code. Transient
|
|
* failures are retried; a genuine conversion failure is final. Durability and
|
|
* restart-survival live in the row, not memory: a PENDING row is picked up by
|
|
* the next sweep, a crashed RUNNING row recovered once its lock goes stale.
|
|
*/
|
|
@Injectable()
|
|
export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
|
|
private timer: NodeJS.Timeout | undefined;
|
|
private draining = false;
|
|
private wakeAgain = false;
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly converter: PandocConverter,
|
|
private readonly renderer: GotenbergRenderer,
|
|
private readonly config: AppConfig,
|
|
private readonly logger: PinoLogger,
|
|
// Resolved lazily to break the construction cycle (the import service
|
|
// enqueues via the job service, which wakes this worker).
|
|
private readonly moduleRef: ModuleRef,
|
|
) {
|
|
this.logger.setContext(ConversionWorker.name);
|
|
}
|
|
|
|
private async convert(job: ConversionJob): Promise<{ bytes: Buffer; mimeType: string }> {
|
|
const result: ConversionResult = await this.converter.convert({
|
|
from: job.sourceFormat,
|
|
to: job.targetFormat,
|
|
input: Buffer.from(job.input),
|
|
standalone: job.standalone,
|
|
});
|
|
return { bytes: result.output, mimeType: result.mimeType };
|
|
}
|
|
|
|
onModuleInit(): void {
|
|
if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly
|
|
this.timer = setInterval(() => this.drainSafely(), SWEEP_MS);
|
|
this.timer.unref();
|
|
}
|
|
|
|
/** A sweep hitting a transient database failure (outage, or the backup
|
|
* sidecar terminating connections mid-restore, #103) must degrade and
|
|
* retry on the next tick — an unhandled rejection here killed the whole
|
|
* api once. */
|
|
private drainSafely(): void {
|
|
this.drain().catch((error: unknown) => {
|
|
this.logger.warn({ err: error }, 'conversion sweep failed; retrying on the next tick');
|
|
});
|
|
}
|
|
|
|
onModuleDestroy(): void {
|
|
if (this.timer) clearInterval(this.timer);
|
|
}
|
|
|
|
/** Nudge the worker after an enqueue without blocking the caller. Coalesces
|
|
* concurrent wakes: a drain already in progress is asked to run once more.
|
|
* No-op under test, where tests drive {@link drain} deterministically. */
|
|
wake(): void {
|
|
if (this.config.env.NODE_ENV === 'test') return;
|
|
this.drainSafely();
|
|
}
|
|
|
|
/** Process every claimable job, then stop. Re-entrant-safe: a second call
|
|
* while draining just flags one more pass instead of running in parallel. */
|
|
async drain(): Promise<void> {
|
|
if (this.draining) {
|
|
this.wakeAgain = true;
|
|
return;
|
|
}
|
|
this.draining = true;
|
|
try {
|
|
do {
|
|
this.wakeAgain = false;
|
|
for (let job = await this.claimNext(); job; job = await this.claimNext()) {
|
|
await this.process(job);
|
|
}
|
|
} while (this.wakeAgain);
|
|
} finally {
|
|
this.draining = false;
|
|
}
|
|
}
|
|
|
|
/** Atomically claim the oldest available job (PENDING, or a RUNNING one
|
|
* whose lock has gone stale), or return null when the queue is drained. */
|
|
private async claimNext(): Promise<ConversionJob | null> {
|
|
const staleBefore = new Date(Date.now() - STALE_LOCK_MS);
|
|
const claimed = await this.prisma.$queryRaw<{ id: string }[]>`
|
|
UPDATE conversion_jobs
|
|
SET status = 'RUNNING', locked_at = now(), attempts = attempts + 1
|
|
WHERE id = (
|
|
SELECT id FROM conversion_jobs
|
|
WHERE status = 'PENDING'
|
|
OR (status = 'RUNNING' AND locked_at < ${staleBefore})
|
|
ORDER BY created_at
|
|
FOR UPDATE SKIP LOCKED
|
|
LIMIT 1
|
|
)
|
|
RETURNING id`;
|
|
const id = claimed[0]?.id;
|
|
if (!id) return null;
|
|
return this.prisma.conversionJob.findUnique({ where: { id } });
|
|
}
|
|
|
|
private async process(job: ConversionJob): Promise<void> {
|
|
try {
|
|
if (isImportKind(job.kind)) {
|
|
// Import runs a multi-step pipeline and records its own success (the
|
|
// created page id) on the job (#63). Resolved lazily via a token so the
|
|
// worker's file never imports the import service's (avoids a cycle).
|
|
await this.moduleRef.get<ImportProcessor>(IMPORT_PROCESSOR, { strict: false }).run(job);
|
|
return;
|
|
}
|
|
// A data export gathers the account's own data into a ZIP (#68) whose
|
|
// download link expires; a PDF export renders HTML through Gotenberg
|
|
// (#67); everything else is a pandoc byte→byte conversion (#62/#65).
|
|
let output: { bytes: Buffer; mimeType: string };
|
|
let expiresAt: Date | null = null;
|
|
if (job.kind === DATA_EXPORT_KIND) {
|
|
// Resolved lazily via a token so this file never imports the export
|
|
// service's (avoids a construction cycle), mirroring the import path.
|
|
output = await this.moduleRef
|
|
.get<DataExportProcessor>(DATA_EXPORT_PROCESSOR, { strict: false })
|
|
.build(job);
|
|
expiresAt = new Date(Date.now() + DATA_EXPORT_TTL_MS);
|
|
} else if (job.targetFormat === 'pdf') {
|
|
output = {
|
|
bytes: await this.renderer.renderHtmlToPdf(Buffer.from(job.input).toString('utf8')),
|
|
mimeType: 'application/pdf',
|
|
};
|
|
} else {
|
|
output = await this.convert(job);
|
|
}
|
|
await this.prisma.conversionJob.update({
|
|
where: { id: job.id },
|
|
data: {
|
|
status: 'SUCCEEDED',
|
|
// Prisma Bytes = Uint8Array<ArrayBuffer>; copy the Buffer in (#40).
|
|
result: new Uint8Array(output.bytes),
|
|
resultMimeType: output.mimeType,
|
|
errorCode: null,
|
|
expiresAt,
|
|
},
|
|
});
|
|
this.logger.info(
|
|
{ jobId: job.id, from: job.sourceFormat, to: job.targetFormat },
|
|
'audit: conversion succeeded',
|
|
);
|
|
} catch (error) {
|
|
await this.recordFailure(job, error);
|
|
}
|
|
}
|
|
|
|
private async recordFailure(job: ConversionJob, error: unknown): Promise<void> {
|
|
const typed = error instanceof ConversionError || error instanceof RenderError ? error : null;
|
|
const code = typed?.code ?? 'conversion_failed';
|
|
const retryable = typed?.retryable ?? false;
|
|
// `attempts` was already incremented by the claim, so it reflects this try.
|
|
if (retryable && job.attempts < MAX_ATTEMPTS) {
|
|
await this.prisma.conversionJob.update({
|
|
where: { id: job.id },
|
|
data: { status: 'PENDING', lockedAt: null, errorCode: code },
|
|
});
|
|
this.logger.warn(
|
|
{ jobId: job.id, code, attempt: job.attempts },
|
|
'conversion attempt failed, will retry',
|
|
);
|
|
return;
|
|
}
|
|
await this.prisma.conversionJob.update({
|
|
where: { id: job.id },
|
|
data: { status: 'FAILED', errorCode: code },
|
|
});
|
|
this.logger.error({ jobId: job.id, code, attempts: job.attempts }, 'conversion failed');
|
|
}
|
|
}
|