feat: establish production SaaS foundation

This commit is contained in:
Codex
2026-07-12 19:26:57 +08:00
parent 1c2ce38cea
commit 39f7332f33
219 changed files with 20647 additions and 2628 deletions

View File

@@ -4,8 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts --loop",
"start": "node dist/apps/worker/src/index.js --loop",
"dev": "tsx watch src/index.ts --loop --job crm",
"start": "node dist/apps/worker/src/index.js --loop --job crm",
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit",
"crm:once": "tsx src/index.ts --once --job crm",
@@ -21,7 +21,8 @@
"assets:once": "tsx src/index.ts --once --job assets",
"imports:once": "tsx src/index.ts --once --job imports",
"public-banks:once": "tsx src/index.ts --once --job public-banks",
"exports:once": "tsx src/index.ts --once --job exports"
"exports:once": "tsx src/index.ts --once --job exports",
"student-supervision:once": "tsx src/index.ts --once --job student-supervision"
},
"dependencies": {
"@resvg/resvg-js": "^2.6.2",

146
apps/worker/src/cli.ts Normal file
View File

@@ -0,0 +1,146 @@
export const WORKER_JOBS = [
'crm',
'commerce',
'provider-bills',
'platform-billing',
'platform-usage',
'platform-usage-overage',
'platform-dunning',
'platform-dunning-notifications',
'platform-audit-alerts',
'platform-audit-notifications',
'assets',
'imports',
'public-banks',
'exports',
'student-supervision',
] as const;
export type WorkerJob = typeof WORKER_JOBS[number];
export const CONTINUOUS_WORKER_JOBS = [
'crm',
'commerce',
'provider-bills',
'platform-dunning-notifications',
'platform-audit-notifications',
'assets',
'imports',
'public-banks',
'exports',
] as const satisfies readonly WorkerJob[];
export type ContinuousWorkerJob = typeof CONTINUOUS_WORKER_JOBS[number];
export const PERIODIC_WORKER_JOBS = [
'platform-billing',
'platform-usage',
'platform-usage-overage',
'platform-dunning',
'platform-audit-alerts',
'student-supervision',
] as const satisfies readonly WorkerJob[];
export interface WorkerCliOptions {
job: WorkerJob;
loop: boolean;
month?: string;
}
function optionValues(argv: string[], name: string) {
const values: string[] = [];
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] !== name) continue;
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${name} requires a value`);
}
values.push(value);
index += 1;
}
return values;
}
function optionTokenIndexes(argv: string[]) {
const indexes = new Set<number>();
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value.startsWith('--')) continue;
indexes.add(index);
if (value === '--job' || value === '--month') {
if (argv[index + 1]) indexes.add(index + 1);
index += 1;
}
}
return indexes;
}
function isWorkerJob(value: string): value is WorkerJob {
return (WORKER_JOBS as readonly string[]).includes(value);
}
export function isContinuousWorkerJob(value: WorkerJob): value is ContinuousWorkerJob {
return (CONTINUOUS_WORKER_JOBS as readonly string[]).includes(value);
}
function previousShanghaiMonth(now: Date) {
const currentMonth = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
}).format(now);
const [year, month] = currentMonth.split('-').map(Number);
return new Date(Date.UTC(year, month - 2, 1)).toISOString().slice(0, 7);
}
export function resolveWorkerMonth(value: string, now = new Date()) {
const normalized = value.trim().toLowerCase();
if (normalized === 'previous') return previousShanghaiMonth(now);
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(normalized)) {
throw new Error('--month must be previous or a valid YYYY-MM value');
}
return normalized;
}
export function parseWorkerCli(argv: string[]): WorkerCliOptions {
const loop = argv.includes('--loop');
const once = argv.includes('--once');
if (loop && once) throw new Error('Choose exactly one worker mode: --loop or --once');
const jobValues = optionValues(argv, '--job');
if (jobValues.length !== 1) {
throw new Error('--job is required exactly once; the worker has no implicit default job');
}
const [job] = jobValues;
if (!isWorkerJob(job)) {
throw new Error(`Unsupported worker job: ${job}. Expected one of: ${WORKER_JOBS.join(', ')}`);
}
if (loop && !isContinuousWorkerJob(job)) {
throw new Error(`Worker job ${job} is periodic and must be scheduled with --once`);
}
const monthValues = optionValues(argv, '--month');
if (monthValues.length > 1) throw new Error('--month may only be provided once');
if (monthValues.length > 0 && !['platform-usage', 'platform-usage-overage'].includes(job)) {
throw new Error('--month is only supported by platform-usage and platform-usage-overage');
}
if (loop && monthValues.length > 0) throw new Error('--month cannot be used with --loop');
const recognizedOptions = new Set(['--loop', '--once', '--job', '--month']);
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value.startsWith('--')) continue;
if (!recognizedOptions.has(value)) throw new Error(`Unknown worker option: ${value}`);
if (value === '--job' || value === '--month') index += 1;
}
const consumedIndexes = optionTokenIndexes(argv);
for (let index = 0; index < argv.length; index += 1) {
if (!consumedIndexes.has(index)) throw new Error(`Unexpected worker argument: ${argv[index]}`);
}
return {
job,
loop,
month: monthValues[0] ? resolveWorkerMonth(monthValues[0]) : undefined,
};
}

View File

@@ -13,9 +13,11 @@ export interface WorkerConfig {
crmRequestTimeoutMs: number;
crmAllowInsecureLocalhost: boolean;
commerceBatchSize: number;
commercePollIntervalMs: number;
commerceMinAgeSeconds: number;
commerceRequestTimeoutMs: number;
providerBillBatchSize: number;
providerBillPollIntervalMs: number;
providerBillWorkerId: string;
providerBillClaimStaleSeconds: number;
platformBillingBatchSize: number;
@@ -32,6 +34,7 @@ export interface WorkerConfig {
platformDunningBatchSize: number;
platformDunningWorkerId: string;
platformDunningNotificationBatchSize: number;
platformDunningNotificationPollIntervalMs: number;
platformDunningNotificationMaxAttempts: number;
platformDunningNotificationBackoffSeconds: number[];
platformDunningNotificationRequestTimeoutMs: number;
@@ -40,11 +43,13 @@ export interface WorkerConfig {
platformAuditAlertWorkerId: string;
platformAuditAlertLookbackDays: number;
platformAuditNotificationBatchSize: number;
platformAuditNotificationPollIntervalMs: number;
platformAuditNotificationMaxAttempts: number;
platformAuditNotificationBackoffSeconds: number[];
platformAuditNotificationRequestTimeoutMs: number;
platformAuditNotificationAllowInsecureLocalhost: boolean;
assetBatchSize: number;
assetPollIntervalMs: number;
assetMinAgeSeconds: number;
assetRecheckIntervalSeconds: number;
assetRequestTimeoutMs: number;
@@ -54,13 +59,18 @@ export interface WorkerConfig {
assetSecurityScanHttpTimeoutMs: number;
assetSecurityScanFailOpen: boolean;
importBatchSize: number;
importPollIntervalMs: number;
importWorkerId: string;
importLeaseSeconds: number;
importHeartbeatIntervalMs: number;
importBackoffSeconds: number[];
publicBankSyncBatchSize: number;
publicBankSyncPollIntervalMs: number;
publicBankSyncCopyLimit: number;
publicBankSyncWorkerId: string;
publicBankSyncClaimStaleSeconds: number;
exportBatchSize: number;
exportPollIntervalMs: number;
exportWorkerId: string;
exportBackoffSeconds: number[];
studentSupervisionBatchSize: number;
@@ -161,6 +171,32 @@ function validateProductionConfig(nextConfig: WorkerConfig) {
if (nextConfig.platformDunningNotificationAllowInsecureLocalhost) {
failures.push('WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true is not allowed in production workers');
}
const pollIntervals = [
['WORKER_CRM_POLL_INTERVAL_MS', nextConfig.crmPollIntervalMs],
['WORKER_COMMERCE_POLL_INTERVAL_MS', nextConfig.commercePollIntervalMs],
['WORKER_PROVIDER_BILL_POLL_INTERVAL_MS', nextConfig.providerBillPollIntervalMs],
['WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS', nextConfig.platformDunningNotificationPollIntervalMs],
['WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS', nextConfig.platformAuditNotificationPollIntervalMs],
['WORKER_ASSET_POLL_INTERVAL_MS', nextConfig.assetPollIntervalMs],
['WORKER_IMPORT_POLL_INTERVAL_MS', nextConfig.importPollIntervalMs],
['WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS', nextConfig.publicBankSyncPollIntervalMs],
['WORKER_EXPORT_POLL_INTERVAL_MS', nextConfig.exportPollIntervalMs],
] as const;
for (const [name, value] of pollIntervals) {
if (!Number.isFinite(value) || value < 1_000 || value > 3_600_000) {
failures.push(`${name} must be between 1000 and 3600000`);
}
}
if (!Number.isFinite(nextConfig.importLeaseSeconds) || nextConfig.importLeaseSeconds < 10 || nextConfig.importLeaseSeconds > 86_400) {
failures.push('WORKER_IMPORT_LEASE_SECONDS must be between 10 and 86400');
}
if (
!Number.isFinite(nextConfig.importHeartbeatIntervalMs)
|| nextConfig.importHeartbeatIntervalMs < 1_000
|| nextConfig.importHeartbeatIntervalMs >= nextConfig.importLeaseSeconds * 500
) {
failures.push('WORKER_IMPORT_HEARTBEAT_INTERVAL_MS must be at least 1000 and less than half the lease duration');
}
const scannerModes = nextConfig.assetSecurityScanner
.split(',')
.map(item => item.trim().toLowerCase())
@@ -229,9 +265,11 @@ const loadedConfig: WorkerConfig = {
crmRequestTimeoutMs: envNumber('WORKER_CRM_REQUEST_TIMEOUT_MS', 10_000),
crmAllowInsecureLocalhost: envBoolean('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false),
commerceBatchSize: envNumber('WORKER_COMMERCE_BATCH_SIZE', 20),
commercePollIntervalMs: envNumber('WORKER_COMMERCE_POLL_INTERVAL_MS', 30_000),
commerceMinAgeSeconds: envNumber('WORKER_COMMERCE_MIN_AGE_SECONDS', 300),
commerceRequestTimeoutMs: envNumber('WORKER_COMMERCE_REQUEST_TIMEOUT_MS', 10_000),
providerBillBatchSize: envNumber('WORKER_PROVIDER_BILL_BATCH_SIZE', 5),
providerBillPollIntervalMs: envNumber('WORKER_PROVIDER_BILL_POLL_INTERVAL_MS', 60_000),
providerBillWorkerId: envString('WORKER_PROVIDER_BILL_ID', `provider-bills-${process.pid}`),
providerBillClaimStaleSeconds: envNumber('WORKER_PROVIDER_BILL_CLAIM_STALE_SECONDS', 15 * 60),
platformBillingBatchSize: envNumber('WORKER_PLATFORM_BILLING_BATCH_SIZE', 50),
@@ -248,6 +286,7 @@ const loadedConfig: WorkerConfig = {
platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100),
platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`),
platformDunningNotificationBatchSize: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE', 50),
platformDunningNotificationPollIntervalMs: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS', 30_000),
platformDunningNotificationMaxAttempts: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_MAX_ATTEMPTS', 5),
platformDunningNotificationBackoffSeconds: envList('WORKER_PLATFORM_DUNNING_NOTIFICATION_BACKOFF_SECONDS', '10,60,300,900,1800')
.map((value: string) => Number(value))
@@ -258,6 +297,7 @@ const loadedConfig: WorkerConfig = {
platformAuditAlertWorkerId: envString('WORKER_PLATFORM_AUDIT_ALERT_ID', `platform-audit-alerts-${process.pid}`),
platformAuditAlertLookbackDays: envNumber('WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS', 14),
platformAuditNotificationBatchSize: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE', 50),
platformAuditNotificationPollIntervalMs: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS', 30_000),
platformAuditNotificationMaxAttempts: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_MAX_ATTEMPTS', 5),
platformAuditNotificationBackoffSeconds: envList('WORKER_PLATFORM_AUDIT_NOTIFICATION_BACKOFF_SECONDS', '10,60,300,900,1800')
.map((value: string) => Number(value))
@@ -265,6 +305,7 @@ const loadedConfig: WorkerConfig = {
platformAuditNotificationRequestTimeoutMs: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_REQUEST_TIMEOUT_MS', 10_000),
platformAuditNotificationAllowInsecureLocalhost: envBoolean('WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false),
assetBatchSize: envNumber('WORKER_ASSET_BATCH_SIZE', 50),
assetPollIntervalMs: envNumber('WORKER_ASSET_POLL_INTERVAL_MS', 30_000),
assetMinAgeSeconds: envNumber('WORKER_ASSET_MIN_AGE_SECONDS', 300),
assetRecheckIntervalSeconds: envNumber('WORKER_ASSET_RECHECK_INTERVAL_SECONDS', 60 * 60 * 24),
assetRequestTimeoutMs: envNumber('WORKER_ASSET_REQUEST_TIMEOUT_MS', 10_000),
@@ -274,15 +315,20 @@ const loadedConfig: WorkerConfig = {
assetSecurityScanHttpTimeoutMs: envNumber('WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS', 10_000),
assetSecurityScanFailOpen: envBoolean('WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN', false),
importBatchSize: envNumber('WORKER_IMPORT_BATCH_SIZE', 5),
importPollIntervalMs: envNumber('WORKER_IMPORT_POLL_INTERVAL_MS', 10_000),
importWorkerId: envString('WORKER_IMPORT_ID', `imports-${process.pid}`),
importLeaseSeconds: envNumber('WORKER_IMPORT_LEASE_SECONDS', 120),
importHeartbeatIntervalMs: envNumber('WORKER_IMPORT_HEARTBEAT_INTERVAL_MS', 30_000),
importBackoffSeconds: envList('WORKER_IMPORT_BACKOFF_SECONDS', '30,120,600,1800')
.map((value: string) => Number(value))
.filter((value: number) => Number.isFinite(value) && value > 0),
publicBankSyncBatchSize: envNumber('WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE', 5),
publicBankSyncPollIntervalMs: envNumber('WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS', 60_000),
publicBankSyncCopyLimit: envNumber('WORKER_PUBLIC_BANK_SYNC_COPY_LIMIT', 1000),
publicBankSyncWorkerId: envString('WORKER_PUBLIC_BANK_SYNC_ID', `public-banks-${process.pid}`),
publicBankSyncClaimStaleSeconds: envNumber('WORKER_PUBLIC_BANK_SYNC_CLAIM_STALE_SECONDS', 15 * 60),
exportBatchSize: envNumber('WORKER_EXPORT_BATCH_SIZE', 5),
exportPollIntervalMs: envNumber('WORKER_EXPORT_POLL_INTERVAL_MS', 10_000),
exportWorkerId: envString('WORKER_EXPORT_ID', `exports-${process.pid}`),
exportBackoffSeconds: envList('WORKER_EXPORT_BACKOFF_SECONDS', '30,120,600,1800')
.map((value: string) => Number(value))

View File

@@ -3,7 +3,7 @@ import { config } from './config.js';
export const pool = createPool({
connectionString: config.databaseUrl,
max: 5,
applicationName: 'tiku-worker',
});
export async function closePool() {

View File

@@ -3,20 +3,15 @@ import { config } from './config.js';
import { processCrmBatch } from './jobs/crm.js';
import { processCommerceBatch } from './jobs/commerce.js';
import { processAssetBatch } from './jobs/assets.js';
import {
parseWorkerCli,
type ContinuousWorkerJob,
type WorkerJob,
} from './cli.js';
const extraClosers = new Set<() => Promise<void>>();
function hasArg(name: string) {
return process.argv.includes(name);
}
function argValue(name: string, fallback = '') {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] || fallback : fallback;
}
async function runOnce() {
const job = argValue('--job', 'crm');
async function runOnce(job: WorkerJob, month?: string) {
if (job === 'crm') {
const result = await processCrmBatch();
console.log(`[worker] crm batch processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`);
@@ -53,7 +48,7 @@ async function runOnce() {
}
if (job === 'platform-usage') {
const { processPlatformUsageBatch } = await import('./jobs/platform-usage.js');
const result = await processPlatformUsageBatch();
const result = await processPlatformUsageBatch({ month });
console.log(
`[worker] platform-usage batch processed=${result.processed}`
+ ` metrics=${result.metrics} created=${result.created}`
@@ -63,7 +58,7 @@ async function runOnce() {
}
if (job === 'platform-usage-overage') {
const { processPlatformUsageOverageBatch } = await import('./jobs/platform-usage-overage.js');
const result = await processPlatformUsageOverageBatch();
const result = await processPlatformUsageOverageBatch({ month });
console.log(
`[worker] platform-usage-overage batch processed=${result.processed}`
+ ` created=${result.created} skipped=${result.skipped}`
@@ -125,7 +120,8 @@ async function runOnce() {
console.log(
`[worker] imports batch processed=${result.processed}`
+ ` completed=${result.completed} completedWithErrors=${result.completedWithErrors}`
+ ` failed=${result.failed} retrying=${result.retrying} skipped=${result.skipped}`,
+ ` failed=${result.failed} retrying=${result.retrying}`
+ ` leaseLost=${result.leaseLost} skipped=${result.skipped}`,
);
return;
}
@@ -166,8 +162,21 @@ async function runOnce() {
throw new Error(`Unsupported worker job: ${job}`);
}
async function runLoop() {
console.log('[worker] started');
function loopPollIntervalMs(job: ContinuousWorkerJob) {
if (job === 'crm') return config.crmPollIntervalMs;
if (job === 'commerce') return config.commercePollIntervalMs;
if (job === 'provider-bills') return config.providerBillPollIntervalMs;
if (job === 'platform-dunning-notifications') return config.platformDunningNotificationPollIntervalMs;
if (job === 'platform-audit-notifications') return config.platformAuditNotificationPollIntervalMs;
if (job === 'assets') return config.assetPollIntervalMs;
if (job === 'imports') return config.importPollIntervalMs;
if (job === 'public-banks') return config.publicBankSyncPollIntervalMs;
return config.exportPollIntervalMs;
}
async function runLoop(job: ContinuousWorkerJob) {
const pollIntervalMs = loopPollIntervalMs(job);
console.log(`[worker] started job=${job} pollIntervalMs=${pollIntervalMs}`);
let stopped = false;
const stop = () => {
stopped = true;
@@ -177,19 +186,21 @@ async function runLoop() {
while (!stopped) {
try {
await runOnce();
await runOnce(job);
} catch (error) {
console.error('[worker] job failed', error);
console.error(`[worker] job=${job} failed`, error);
}
await new Promise(resolve => setTimeout(resolve, config.crmPollIntervalMs));
if (!stopped) await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
}
}
const cli = parseWorkerCli(process.argv.slice(2));
try {
if (hasArg('--loop')) {
await runLoop();
if (cli.loop) {
await runLoop(cli.job as ContinuousWorkerJob);
} else {
await runOnce();
await runOnce(cli.job, cli.month);
}
} finally {
for (const closeExtra of extraClosers) {

View File

@@ -3,7 +3,7 @@ import { config } from '../config.js';
import { executeContentImportJob, type ExecutableContentImportType } from '../../../api/src/features/tenant-content/imports.js';
import { closePool as closeApiImportPool } from '../../../api/src/core/db.js';
interface ImportJobRow {
export interface ImportJobRow {
id: string;
tenantId: string;
createdBy: string | null;
@@ -12,6 +12,8 @@ interface ImportJobRow {
attemptCount: number;
maxAttempts: number;
summary: Record<string, unknown>;
leaseToken: string;
leaseExpiresAt: Date;
}
interface ImportWorkerResult {
@@ -20,9 +22,22 @@ interface ImportWorkerResult {
completedWithErrors: number;
failed: number;
retrying: number;
leaseLost: number;
skipped: number;
}
interface ImportLeaseOptions {
workerId?: string;
batchSize?: number;
leaseSeconds?: number;
heartbeatIntervalMs?: number;
}
interface ImportLeaseHeartbeat {
stop: () => Promise<void>;
ownershipLost: () => boolean;
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
@@ -31,11 +46,6 @@ function boolValue(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
function numberValue(value: unknown, fallback: number) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) ? parsed : fallback;
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
@@ -62,155 +72,341 @@ function importOptions(summary: Record<string, unknown>) {
};
}
async function claimImportJobs() {
function leaseSettings(options: ImportLeaseOptions = {}) {
return {
workerId: options.workerId || config.importWorkerId,
batchSize: options.batchSize ?? config.importBatchSize,
leaseSeconds: options.leaseSeconds ?? config.importLeaseSeconds,
heartbeatIntervalMs: options.heartbeatIntervalMs ?? config.importHeartbeatIntervalMs,
};
}
export async function claimImportJobs(options: ImportLeaseOptions = {}) {
const settings = leaseSettings(options);
const client = await pool.connect();
try {
await client.query('begin');
const result = await client.query<ImportJobRow>(
const exhausted = await client.query<{
id: string;
tenantId: string;
createdBy: string | null;
importType: ExecutableContentImportType;
attemptCount: number;
maxAttempts: number;
lockedBy: string | null;
}>(
`
select id,
tenant_id as "tenantId",
created_by as "createdBy",
import_type as "importType",
status,
attempt_count as "attemptCount",
max_attempts as "maxAttempts",
summary
from public.content_import_jobs
update public.content_import_jobs
set status = 'failed',
error_message = 'Import worker lease expired after the final attempt',
summary = coalesce(summary, '{}'::jsonb) || jsonb_build_object(
'lastWorkerError', jsonb_build_object(
'code', 'IMPORT_WORKER_LEASE_EXPIRED',
'message', 'Import worker lease expired after the final attempt',
'workerId', locked_by,
'failedAt', now(),
'attemptCount', attempt_count,
'maxAttempts', max_attempts,
'willRetry', false
)
),
next_attempt_at = null,
locked_at = null,
locked_by = null,
lease_token = null,
lease_expires_at = null,
last_heartbeat_at = null,
finished_at = now(),
updated_at = now()
where execution_mode = 'async'
and status = 'pending'
and attempt_count < max_attempts
and (next_attempt_at is null or next_attempt_at <= now())
order by created_at asc
limit $1
for update skip locked
and status = 'importing'
and lease_expires_at <= now()
and attempt_count >= max_attempts
returning id,
tenant_id as "tenantId",
created_by as "createdBy",
import_type as "importType",
attempt_count as "attemptCount",
max_attempts as "maxAttempts",
summary #>> '{lastWorkerError,workerId}' as "lockedBy"
`,
[config.importBatchSize],
);
const ids = result.rows.map(row => row.id);
if (ids.length > 0) {
for (const job of exhausted.rows) {
await client.query(
`
update public.content_import_jobs
set locked_at = now(),
locked_by = $2,
attempt_count = attempt_count + 1,
updated_at = now()
where id = any($1::uuid[])
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, 'content_import_job', $4, $5::jsonb)
`,
[ids, config.importWorkerId],
[
job.tenantId,
job.createdBy,
`content.import.${job.importType}.failed`,
job.id,
JSON.stringify({
code: 'IMPORT_WORKER_LEASE_EXPIRED',
workerId: job.lockedBy,
attemptCount: job.attemptCount,
maxAttempts: job.maxAttempts,
}),
],
);
}
const result = await client.query<ImportJobRow>(
`
with candidates as (
select id
from public.content_import_jobs
where execution_mode = 'async'
and attempt_count < max_attempts
and (
(
status = 'pending'
and (next_attempt_at is null or next_attempt_at <= now())
)
or (
status = 'importing'
and lease_expires_at <= now()
)
)
order by
case when status = 'importing' then 0 else 1 end,
coalesce(lease_expires_at, next_attempt_at, created_at) asc,
created_at asc,
id asc
limit $1
for update skip locked
)
update public.content_import_jobs job
set status = 'importing',
dry_run = false,
locked_at = now(),
locked_by = $2,
lease_token = gen_random_uuid(),
lease_expires_at = now() + make_interval(secs => $3::integer),
last_heartbeat_at = now(),
attempt_count = job.attempt_count + 1,
next_attempt_at = null,
error_message = null,
started_at = coalesce(job.started_at, now()),
finished_at = null,
updated_at = now()
from candidates
where job.id = candidates.id
returning job.id,
job.tenant_id as "tenantId",
job.created_by as "createdBy",
job.import_type as "importType",
job.status,
job.attempt_count as "attemptCount",
job.max_attempts as "maxAttempts",
job.summary,
job.lease_token as "leaseToken",
job.lease_expires_at as "leaseExpiresAt"
`,
[settings.batchSize, settings.workerId, settings.leaseSeconds],
);
await client.query('commit');
return result.rows;
} catch (error) {
await client.query('rollback');
await client.query('rollback').catch(() => undefined);
throw error;
} finally {
client.release();
}
}
async function markImportFailed(job: ImportJobRow, error: unknown) {
const nextAttempt = job.attemptCount + 1;
const willRetry = nextAttempt < job.maxAttempts;
export function startImportLeaseHeartbeat(
job: Pick<ImportJobRow, 'id' | 'tenantId' | 'leaseToken'>,
options: ImportLeaseOptions = {},
): ImportLeaseHeartbeat {
const settings = leaseSettings(options);
let stopped = false;
let lost = false;
let inFlight: Promise<void> | null = null;
const heartbeat = async () => {
try {
const result = await pool.query(
`
update public.content_import_jobs
set lease_expires_at = now() + make_interval(secs => $4::integer),
last_heartbeat_at = now(),
updated_at = now()
where tenant_id = $1
and id = $2
and status = 'importing'
and lease_token = $3::uuid
and lease_expires_at > now()
returning id
`,
[job.tenantId, job.id, job.leaseToken, settings.leaseSeconds],
);
if (result.rowCount !== 1) lost = true;
} catch (error) {
console.error(`[worker] import lease heartbeat failed jobId=${job.id}`, error);
}
};
const timer = setInterval(() => {
if (stopped || inFlight) return;
inFlight = heartbeat().finally(() => {
inFlight = null;
});
}, settings.heartbeatIntervalMs);
timer.unref();
return {
ownershipLost: () => lost,
stop: async () => {
stopped = true;
clearInterval(timer);
if (inFlight) await inFlight;
},
};
}
export async function markImportFailed(job: ImportJobRow, error: unknown) {
const willRetry = job.attemptCount < job.maxAttempts;
const status = willRetry ? 'pending' : 'failed';
await pool.query(
`
update public.content_import_jobs
set status = $3,
error_message = $4,
summary = coalesce(summary, '{}'::jsonb) || $5::jsonb,
next_attempt_at = case when $6::boolean then now() + make_interval(secs => $7::integer) else null end,
locked_at = null,
locked_by = null,
finished_at = case when $3 = 'failed' then now() else finished_at end,
updated_at = now()
where tenant_id = $1 and id = $2
`,
[
job.tenantId,
job.id,
status,
truncate(errorMessage(error)),
JSON.stringify({
lastWorkerError: {
const client = await pool.connect();
try {
await client.query('begin');
const updated = await client.query(
`
update public.content_import_jobs
set status = $4,
error_message = $5,
summary = coalesce(summary, '{}'::jsonb) || $6::jsonb,
next_attempt_at = case when $7::boolean then now() + make_interval(secs => $8::integer) else null end,
locked_at = null,
locked_by = null,
lease_token = null,
lease_expires_at = null,
last_heartbeat_at = null,
finished_at = case when $4 = 'failed' then now() else null end,
updated_at = now()
where tenant_id = $1
and id = $2
and status = 'importing'
and lease_token = $3::uuid
and lease_expires_at > now()
returning id
`,
[
job.tenantId,
job.id,
job.leaseToken,
status,
truncate(errorMessage(error)),
JSON.stringify({
lastWorkerError: {
code: errorCode(error),
message: truncate(errorMessage(error)),
workerId: config.importWorkerId,
failedAt: new Date().toISOString(),
attemptCount: job.attemptCount,
maxAttempts: job.maxAttempts,
willRetry,
},
}),
willRetry,
backoffSeconds(job.attemptCount),
],
);
if (updated.rowCount !== 1) {
await client.query('rollback');
return 'lease_lost' as const;
}
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, 'content_import_job', $4, $5::jsonb)
`,
[
job.tenantId,
job.createdBy,
willRetry ? `content.import.${job.importType}.retry_scheduled` : `content.import.${job.importType}.failed`,
job.id,
JSON.stringify({
code: errorCode(error),
message: truncate(errorMessage(error)),
workerId: config.importWorkerId,
failedAt: new Date().toISOString(),
nextAttempt,
attemptCount: job.attemptCount,
maxAttempts: job.maxAttempts,
willRetry,
},
}),
willRetry,
backoffSeconds(nextAttempt),
],
);
await pool.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, 'content_import_job', $4, $5::jsonb)
`,
[
job.tenantId,
job.createdBy,
willRetry ? `content.import.${job.importType}.retry_scheduled` : `content.import.${job.importType}.failed`,
job.id,
JSON.stringify({
code: errorCode(error),
message: truncate(errorMessage(error)),
workerId: config.importWorkerId,
nextAttempt,
maxAttempts: job.maxAttempts,
}),
],
);
return willRetry ? 'retrying' : 'failed';
}),
],
);
await client.query('commit');
return willRetry ? 'retrying' as const : 'failed' as const;
} catch (failure) {
await client.query('rollback').catch(() => undefined);
throw failure;
} finally {
client.release();
}
}
export async function processImportBatch(): Promise<ImportWorkerResult> {
const jobs = await claimImportJobs();
const heartbeats = new Map(
jobs.map(job => [job.id, startImportLeaseHeartbeat(job)]),
);
const result: ImportWorkerResult = {
processed: jobs.length,
completed: 0,
completedWithErrors: 0,
failed: 0,
retrying: 0,
leaseLost: 0,
skipped: 0,
};
for (const job of jobs) {
try {
const execution = await executeContentImportJob(
{
tenantId: job.tenantId,
userId: job.createdBy || job.tenantId,
role: 'system_worker',
permissions: { 'content:*': true },
templatePermissions: {},
},
{
jobId: job.id,
importType: job.importType,
allowPartial: importOptions(job.summary).allowPartial,
allowQueuedJob: true,
},
);
try {
for (const job of jobs) {
const heartbeat = heartbeats.get(job.id);
try {
const execution = await executeContentImportJob(
{
tenantId: job.tenantId,
userId: job.createdBy || job.tenantId,
role: 'system_worker',
permissions: { 'content:*': true },
templatePermissions: {},
},
{
jobId: job.id,
importType: job.importType,
allowPartial: importOptions(job.summary).allowPartial,
allowQueuedJob: true,
leaseToken: job.leaseToken,
},
);
if (execution.idempotent) result.skipped += 1;
else if (execution.status === 'completed_with_errors') result.completedWithErrors += 1;
else if (execution.status === 'completed') result.completed += 1;
else result.skipped += 1;
} catch (error) {
const state = await markImportFailed(job, error);
if (state === 'retrying') result.retrying += 1;
else result.failed += 1;
if (execution.idempotent) result.skipped += 1;
else if (execution.status === 'completed_with_errors') result.completedWithErrors += 1;
else if (execution.status === 'completed') result.completed += 1;
else result.skipped += 1;
} catch (error) {
const state = await markImportFailed(job, error);
if (state === 'retrying') result.retrying += 1;
else if (state === 'failed') result.failed += 1;
else {
result.leaseLost += 1;
result.skipped += 1;
}
} finally {
await heartbeat?.stop();
heartbeats.delete(job.id);
}
}
} finally {
await Promise.all([...heartbeats.values()].map(heartbeat => heartbeat.stop()));
}
return result;