Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m12s
CI / Build container images (pull_request) Successful in 3m28s
CI / Auth e2e pack (pull_request) Successful in 8m33s
CI / Import/export fidelity gate (pull_request) Successful in 1m2s
CD / Build and push images (push) Successful in 29s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Failing after 5m9s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
The raw input/result bytes of import/export conversion jobs were kept forever; a deleted classified page could live on inside its last export. A new daily conversion-payload-prune job nulls both once a finished (succeeded or failed) job passes conversion.payloadRetentionDays (instance setting, default 30) — the row survives for status/audit. PENDING and RUNNING rows keep their payload, so the worker's stale-lock recovery path is untouched; a hand-requeued pruned job fails finally via conversionInputOf instead of crashing the worker. The input column becomes nullable; the migration backfills by clearing payloads of jobs already finished longer ago than the default period (recent results stay downloadable until they age out). Job-count fence in system.spec: 7 -> 8 (new scheduler registration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
177 lines
6.7 KiB
TypeScript
177 lines
6.7 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
|
|
import { AppConfig } from '../config/app-config.service';
|
|
|
|
/** A single pandoc conversion request (ADR 0009). `input` is the raw source
|
|
* bytes; the converter base64-encodes it for pandoc when `from` is a binary
|
|
* format (docx/odt/…) and passes it as text otherwise. */
|
|
export interface ConversionRequest {
|
|
from: string;
|
|
to: string;
|
|
input: Buffer;
|
|
standalone?: boolean;
|
|
/** Inline referenced/embedded resources (images) as `data:` URIs in the
|
|
* output. Used by the import pipeline (#63): pandoc-server is stateless and
|
|
* will not hand back a document's media bytes any other way. */
|
|
embedResources?: boolean;
|
|
/** Line-wrapping of the writer's output. Import uses `none` so a paragraph
|
|
* stays on one line (no soft breaks inside image alt text or links). */
|
|
wrap?: 'none' | 'auto' | 'preserve';
|
|
}
|
|
|
|
export interface ConversionResult {
|
|
output: Buffer;
|
|
mimeType: string;
|
|
}
|
|
|
|
export type ConversionErrorCode =
|
|
| 'converter_unavailable'
|
|
| 'converter_timeout'
|
|
| 'conversion_failed'
|
|
// The pond ran out of storage while an import stored the document's media
|
|
// (#63) — final, and surfaced the same way through the worker.
|
|
| 'quota_exceeded'
|
|
// Vault import (#117): rejected archive / unpacked ceiling exceeded.
|
|
| 'import_vault_invalid_zip'
|
|
| 'import_vault_too_large';
|
|
|
|
/** A conversion failure with a stable, localizable code. `retryable` marks
|
|
* the transient causes (sidecar down / timed out) the worker retries before
|
|
* giving up; a genuine `conversion_failed` (bad/unsupported content) is not
|
|
* retried. */
|
|
export class ConversionError extends Error {
|
|
constructor(
|
|
readonly code: ConversionErrorCode,
|
|
readonly retryable: boolean,
|
|
message?: string,
|
|
) {
|
|
super(message ?? code);
|
|
this.name = 'ConversionError';
|
|
}
|
|
}
|
|
|
|
/** The job's input bytes. Since #233 the column is nullable — the retention
|
|
* job prunes finished jobs' payloads. It never touches PENDING/RUNNING rows
|
|
* (incl. stale-lock recovery), so a claimed job without input was re-queued
|
|
* by hand; fail it finally instead of crashing the worker. */
|
|
export function conversionInputOf(job: { input: Uint8Array | null }): Uint8Array {
|
|
if (!job.input) {
|
|
throw new ConversionError('conversion_failed', false, 'input payload was pruned');
|
|
}
|
|
return job.input;
|
|
}
|
|
|
|
/** Server-side conversion limits (ADR 0009). Input is checked before the
|
|
* sidecar call; output is capped while reading the response so a runaway
|
|
* conversion can't exhaust memory. */
|
|
export const MAX_CONVERSION_INPUT_BYTES = 25 * 1024 * 1024;
|
|
export const MAX_CONVERSION_OUTPUT_BYTES = 50 * 1024 * 1024;
|
|
/** Per ADR 0009 / issue #62: a single conversion may run for at most 60 s. */
|
|
export const CONVERSION_TIMEOUT_MS = 60_000;
|
|
|
|
/** Formats pandoc reads as binary — their bytes are base64-encoded in the
|
|
* request `text` field; text formats are sent verbatim. */
|
|
const BINARY_INPUT_FORMATS = new Set(['docx', 'odt', 'epub', 'pptx']);
|
|
|
|
/** Served content type per pandoc output format. */
|
|
const OUTPUT_MIME_TYPES: Readonly<Record<string, string>> = {
|
|
html: 'text/html',
|
|
markdown: 'text/markdown',
|
|
gfm: 'text/markdown',
|
|
commonmark: 'text/markdown',
|
|
plain: 'text/plain',
|
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
odt: 'application/vnd.oasis.opendocument.text',
|
|
};
|
|
|
|
/**
|
|
* Typed client for the pandoc-server sidecar (ADR 0009). Abstract so the job
|
|
* worker and its tests depend on the contract, not the HTTP transport — the
|
|
* queue-mechanics tests inject a fake, this real implementation is exercised
|
|
* against a running container in the integration test.
|
|
*/
|
|
export abstract class PandocConverter {
|
|
abstract convert(request: ConversionRequest): Promise<ConversionResult>;
|
|
abstract reachable(): Promise<boolean>;
|
|
}
|
|
|
|
@Injectable()
|
|
export class PandocServerConverter extends PandocConverter {
|
|
/** Overridable so the timeout path is testable without a 60 s wait. */
|
|
protected timeoutMs = CONVERSION_TIMEOUT_MS;
|
|
|
|
constructor(private readonly config: AppConfig) {
|
|
super();
|
|
}
|
|
|
|
private get baseUrl(): string {
|
|
return this.config.env.PANDOC_URL;
|
|
}
|
|
|
|
async reachable(): Promise<boolean> {
|
|
try {
|
|
const response = await fetch(`${this.baseUrl}/version`);
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async convert(request: ConversionRequest): Promise<ConversionResult> {
|
|
if (request.input.byteLength > MAX_CONVERSION_INPUT_BYTES) {
|
|
throw new ConversionError('conversion_failed', false, 'input exceeds size limit');
|
|
}
|
|
const text = BINARY_INPUT_FORMATS.has(request.from)
|
|
? request.input.toString('base64')
|
|
: request.input.toString('utf8');
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`${this.baseUrl}/`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
text,
|
|
from: request.from,
|
|
to: request.to,
|
|
standalone: request.standalone ?? true,
|
|
// pandoc-server uses hyphenated option keys; unknown keys are ignored,
|
|
// so these are only present when the import pipeline sets them.
|
|
...(request.embedResources ? { 'embed-resources': true } : {}),
|
|
...(request.wrap ? { wrap: request.wrap } : {}),
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
} catch (error) {
|
|
// AbortController.abort() surfaces as an AbortError → the run timed out;
|
|
// anything else means the sidecar could not be reached.
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
throw new ConversionError('converter_timeout', true, 'pandoc timed out');
|
|
}
|
|
throw new ConversionError('converter_unavailable', true, shortMessage(error));
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
// Non-200 = pandoc rejected the content (unknown format, malformed
|
|
// document). Not retryable — the same input fails the same way.
|
|
const detail = (await response.text().catch(() => '')).slice(0, 300);
|
|
throw new ConversionError('conversion_failed', false, detail || `pandoc ${response.status}`);
|
|
}
|
|
|
|
const output = Buffer.from(await response.arrayBuffer());
|
|
if (output.byteLength > MAX_CONVERSION_OUTPUT_BYTES) {
|
|
throw new ConversionError('conversion_failed', false, 'output exceeds size limit');
|
|
}
|
|
return { output, mimeType: OUTPUT_MIME_TYPES[request.to] ?? 'application/octet-stream' };
|
|
}
|
|
}
|
|
|
|
function shortMessage(error: unknown): string {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return message.split('\n')[0]?.slice(0, 200) ?? 'unknown error';
|
|
}
|