forked from wangziqi/gongxue-base
feat: establish production SaaS foundation
This commit is contained in:
@@ -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
146
apps/worker/src/cli.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user