mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): dead-letter retired worker task types (#2323)
This commit is contained in:
@@ -131,7 +131,7 @@ export class JetStreamWorkerQueue {
|
|||||||
}
|
}
|
||||||
const jsm = await this.connectionManager.getJetStreamManager();
|
const jsm = await this.connectionManager.getJetStreamManager();
|
||||||
for (const lane of lanes) {
|
for (const lane of lanes) {
|
||||||
const filterSubjects = lane.taskTypes.map((t) => `${SUBJECT_PREFIX}${t}`);
|
const filterSubjects = [...lane.taskTypes, ...lane.retiredTaskTypes].map((t) => `${SUBJECT_PREFIX}${t}`);
|
||||||
const config = {
|
const config = {
|
||||||
durable_name: lane.consumerName,
|
durable_name: lane.consumerName,
|
||||||
ack_policy: AckPolicy.Explicit,
|
ack_policy: AckPolicy.Explicit,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {APIWorkerLaneName, APIWorkerMode} from '../config/APIConfig';
|
|||||||
interface LaneSettings {
|
interface LaneSettings {
|
||||||
readonly consumerName: string;
|
readonly consumerName: string;
|
||||||
readonly tasks: ReadonlyArray<string>;
|
readonly tasks: ReadonlyArray<string>;
|
||||||
|
readonly retiredTasks: ReadonlyArray<string>;
|
||||||
readonly concurrency: number;
|
readonly concurrency: number;
|
||||||
readonly maxAckPending: number;
|
readonly maxAckPending: number;
|
||||||
readonly ackWaitMs: number;
|
readonly ackWaitMs: number;
|
||||||
@@ -15,6 +16,7 @@ const LANE_CONFIG = {
|
|||||||
realtime: {
|
realtime: {
|
||||||
consumerName: 'workers_realtime',
|
consumerName: 'workers_realtime',
|
||||||
tasks: ['handleMentions', 'handleMentionChunk'] as const,
|
tasks: ['handleMentions', 'handleMentionChunk'] as const,
|
||||||
|
retiredTasks: [],
|
||||||
concurrency: 10,
|
concurrency: 10,
|
||||||
maxAckPending: 50,
|
maxAckPending: 50,
|
||||||
ackWaitMs: 15000,
|
ackWaitMs: 15000,
|
||||||
@@ -23,6 +25,7 @@ const LANE_CONFIG = {
|
|||||||
unfurl: {
|
unfurl: {
|
||||||
consumerName: 'workers_unfurl',
|
consumerName: 'workers_unfurl',
|
||||||
tasks: ['extractEmbeds'] as const,
|
tasks: ['extractEmbeds'] as const,
|
||||||
|
retiredTasks: [],
|
||||||
concurrency: 20,
|
concurrency: 20,
|
||||||
maxAckPending: 200,
|
maxAckPending: 200,
|
||||||
ackWaitMs: 30000,
|
ackWaitMs: 30000,
|
||||||
@@ -54,6 +57,7 @@ const LANE_CONFIG = {
|
|||||||
'bulkAddGuildMembers',
|
'bulkAddGuildMembers',
|
||||||
'bulkBanFileShas',
|
'bulkBanFileShas',
|
||||||
] as const,
|
] as const,
|
||||||
|
retiredTasks: ['sendScheduledMessage'],
|
||||||
concurrency: 8,
|
concurrency: 8,
|
||||||
maxAckPending: 50,
|
maxAckPending: 50,
|
||||||
ackWaitMs: 60000,
|
ackWaitMs: 60000,
|
||||||
@@ -79,6 +83,7 @@ const LANE_CONFIG = {
|
|||||||
'syncFileShaBlocklists',
|
'syncFileShaBlocklists',
|
||||||
'flushUserActivityBuffer',
|
'flushUserActivityBuffer',
|
||||||
] as const,
|
] as const,
|
||||||
|
retiredTasks: [],
|
||||||
concurrency: 12,
|
concurrency: 12,
|
||||||
maxAckPending: 100,
|
maxAckPending: 100,
|
||||||
ackWaitMs: 120000,
|
ackWaitMs: 120000,
|
||||||
@@ -92,6 +97,7 @@ interface WorkerLaneDefinition {
|
|||||||
name: APIWorkerLaneName;
|
name: APIWorkerLaneName;
|
||||||
consumerName: string;
|
consumerName: string;
|
||||||
taskTypes: ReadonlyArray<WorkerTaskName>;
|
taskTypes: ReadonlyArray<WorkerTaskName>;
|
||||||
|
retiredTaskTypes: ReadonlyArray<string>;
|
||||||
concurrency: number;
|
concurrency: number;
|
||||||
maxAckPending: number;
|
maxAckPending: number;
|
||||||
ackWaitMs: number;
|
ackWaitMs: number;
|
||||||
@@ -111,6 +117,7 @@ function makeLane(name: APIWorkerLaneName): WorkerLaneDefinition {
|
|||||||
name,
|
name,
|
||||||
consumerName: config.consumerName,
|
consumerName: config.consumerName,
|
||||||
taskTypes: config.tasks,
|
taskTypes: config.tasks,
|
||||||
|
retiredTaskTypes: config.retiredTasks,
|
||||||
concurrency: config.concurrency,
|
concurrency: config.concurrency,
|
||||||
maxAckPending: config.maxAckPending,
|
maxAckPending: config.maxAckPending,
|
||||||
ackWaitMs: config.ackWaitMs,
|
ackWaitMs: config.ackWaitMs,
|
||||||
@@ -160,6 +167,7 @@ function resolveSingleTaskLane(taskName: WorkerTaskName | undefined): WorkerLane
|
|||||||
name: parentLane.name,
|
name: parentLane.name,
|
||||||
consumerName: `worker_${taskName}`,
|
consumerName: `worker_${taskName}`,
|
||||||
taskTypes: [taskName],
|
taskTypes: [taskName],
|
||||||
|
retiredTaskTypes: [],
|
||||||
concurrency: parentLane.concurrency,
|
concurrency: parentLane.concurrency,
|
||||||
maxAckPending: parentLane.maxAckPending,
|
maxAckPending: parentLane.maxAckPending,
|
||||||
ackWaitMs: parentLane.ackWaitMs,
|
ackWaitMs: parentLane.ackWaitMs,
|
||||||
@@ -231,6 +239,12 @@ function validateLaneCompleteness(registeredTasks: Record<string, unknown>): voi
|
|||||||
if (missingFromRegistry.length > 0) {
|
if (missingFromRegistry.length > 0) {
|
||||||
errors.push(`Lane tasks not found in registry: ${missingFromRegistry.join(', ')}`);
|
errors.push(`Lane tasks not found in registry: ${missingFromRegistry.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
const retiredCollisions = WORKER_LANES.flatMap<string>((lane) => [...lane.retiredTaskTypes]).filter((task) =>
|
||||||
|
registeredTaskNames.has(task),
|
||||||
|
);
|
||||||
|
if (retiredCollisions.length > 0) {
|
||||||
|
errors.push(`Retired tasks registered again: ${retiredCollisions.join(', ')}`);
|
||||||
|
}
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
throw new Error(`Worker lane configuration mismatch:\n${errors.join('\n')}`);
|
throw new Error(`Worker lane configuration mismatch:\n${errors.join('\n')}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ export async function startWorkerMain(): Promise<void> {
|
|||||||
}
|
}
|
||||||
const runner = new WorkerRunner({
|
const runner = new WorkerRunner({
|
||||||
tasks: laneTasks,
|
tasks: laneTasks,
|
||||||
|
retiredTaskTypes: lane.retiredTaskTypes,
|
||||||
queue,
|
queue,
|
||||||
consumerName: lane.consumerName,
|
consumerName: lane.consumerName,
|
||||||
laneName: lane.name,
|
laneName: lane.name,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {isJsonRecord, parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
|||||||
const MAX_DLQ_PUBLISH_ATTEMPTS = 3;
|
const MAX_DLQ_PUBLISH_ATTEMPTS = 3;
|
||||||
const MIN_ACK_HEARTBEAT_MS = 1000;
|
const MIN_ACK_HEARTBEAT_MS = 1000;
|
||||||
const RESUBSCRIBE_DELAY_MS = 5000;
|
const RESUBSCRIBE_DELAY_MS = 5000;
|
||||||
|
const RETIRED_TASK_REASON = 'task type retired';
|
||||||
|
|
||||||
interface WorkerRunnerJetStreamClient {
|
interface WorkerRunnerJetStreamClient {
|
||||||
consumers: {
|
consumers: {
|
||||||
@@ -44,6 +45,7 @@ interface WorkerRunnerQueue {
|
|||||||
|
|
||||||
interface WorkerRunnerOptions {
|
interface WorkerRunnerOptions {
|
||||||
tasks: Record<string, WorkerTaskHandler>;
|
tasks: Record<string, WorkerTaskHandler>;
|
||||||
|
retiredTaskTypes?: ReadonlyArray<string>;
|
||||||
queue: WorkerRunnerQueue;
|
queue: WorkerRunnerQueue;
|
||||||
consumerName: string;
|
consumerName: string;
|
||||||
laneName: string;
|
laneName: string;
|
||||||
@@ -56,6 +58,7 @@ interface WorkerRunnerOptions {
|
|||||||
|
|
||||||
export class WorkerRunner {
|
export class WorkerRunner {
|
||||||
private readonly tasks: Record<string, WorkerTaskHandler>;
|
private readonly tasks: Record<string, WorkerTaskHandler>;
|
||||||
|
private readonly retiredTaskTypes: Set<string>;
|
||||||
private readonly queue: WorkerRunnerQueue;
|
private readonly queue: WorkerRunnerQueue;
|
||||||
private readonly consumerName: string;
|
private readonly consumerName: string;
|
||||||
private readonly laneName: string;
|
private readonly laneName: string;
|
||||||
@@ -72,6 +75,7 @@ export class WorkerRunner {
|
|||||||
|
|
||||||
constructor(options: WorkerRunnerOptions) {
|
constructor(options: WorkerRunnerOptions) {
|
||||||
this.tasks = options.tasks;
|
this.tasks = options.tasks;
|
||||||
|
this.retiredTaskTypes = new Set(options.retiredTaskTypes ?? []);
|
||||||
this.queue = options.queue;
|
this.queue = options.queue;
|
||||||
this.consumerName = options.consumerName;
|
this.consumerName = options.consumerName;
|
||||||
this.laneName = options.laneName;
|
this.laneName = options.laneName;
|
||||||
@@ -198,6 +202,10 @@ export class WorkerRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async processJob(taskType: string, msg: JsMsg): Promise<boolean> {
|
protected async processJob(taskType: string, msg: JsMsg): Promise<boolean> {
|
||||||
|
if (this.retiredTaskTypes.has(taskType)) {
|
||||||
|
await this.retireJob(taskType, msg);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const task = this.tasks[taskType];
|
const task = this.tasks[taskType];
|
||||||
if (!task) {
|
if (!task) {
|
||||||
Logger.error({taskType, seq: msg.seq}, 'Unknown task type, terminating message');
|
Logger.error({taskType, seq: msg.seq}, 'Unknown task type, terminating message');
|
||||||
@@ -364,6 +372,47 @@ export class WorkerRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async retireJob(taskType: string, msg: JsMsg): Promise<void> {
|
||||||
|
const decoded = parseJsonRecord(new TextDecoder().decode(msg.data));
|
||||||
|
const jobPayload = decoded && isJsonRecord(decoded.payload) ? decoded.payload : {};
|
||||||
|
const runAt = decoded && typeof decoded.run_at === 'string' ? decoded.run_at : undefined;
|
||||||
|
let ledgerJobId: bigint | null = null;
|
||||||
|
const embedded = jobPayload['__jobId'];
|
||||||
|
if (typeof embedded === 'string') {
|
||||||
|
try {
|
||||||
|
ledgerJobId = BigInt(embedded);
|
||||||
|
} catch {
|
||||||
|
ledgerJobId = null;
|
||||||
|
}
|
||||||
|
delete jobPayload['__jobId'];
|
||||||
|
}
|
||||||
|
Logger.warn(
|
||||||
|
{taskType, seq: msg.seq, jobId: ledgerJobId?.toString()},
|
||||||
|
'Retired task type from an older release, moving to dead-letter queue',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await this.queue.publishToDlq(taskType, jobPayload, {
|
||||||
|
originalSeq: msg.seq,
|
||||||
|
errorMessage: RETIRED_TASK_REASON,
|
||||||
|
deliveryCount: msg.info.deliveryCount,
|
||||||
|
lane: this.laneName,
|
||||||
|
runAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error({taskType, seq: msg.seq, err: error}, 'Failed to dead-letter a retired job');
|
||||||
|
msg.nak(5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ledgerJobId !== null) {
|
||||||
|
try {
|
||||||
|
await this.ledger.markDeadletter(ledgerJobId, RETIRED_TASK_REASON);
|
||||||
|
} catch (err) {
|
||||||
|
Logger.warn({err, jobId: ledgerJobId.toString()}, 'Ledger markDeadletter failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg.term(RETIRED_TASK_REASON);
|
||||||
|
}
|
||||||
|
|
||||||
private startAckHeartbeat(taskType: string, msg: JsMsg): ReturnType<typeof setInterval> {
|
private startAckHeartbeat(taskType: string, msg: JsMsg): ReturnType<typeof setInterval> {
|
||||||
const heartbeat = setInterval(
|
const heartbeat = setInterval(
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectio
|
|||||||
import {DiscardPolicy, NatsError, RetentionPolicy, StorageType, type StreamConfig} from 'nats';
|
import {DiscardPolicy, NatsError, RetentionPolicy, StorageType, type StreamConfig} from 'nats';
|
||||||
import {describe, expect, it} from 'vitest';
|
import {describe, expect, it} from 'vitest';
|
||||||
import {JetStreamWorkerQueue} from '../JetStreamWorkerQueue';
|
import {JetStreamWorkerQueue} from '../JetStreamWorkerQueue';
|
||||||
|
import {WORKER_LANES} from '../WorkerLaneConfig';
|
||||||
import {WorkerQueueOverflowError} from '../WorkerQueueOverflowError';
|
import {WorkerQueueOverflowError} from '../WorkerQueueOverflowError';
|
||||||
|
|
||||||
const EXPECTED_LIMITS = {
|
const EXPECTED_LIMITS = {
|
||||||
@@ -26,6 +27,11 @@ const LEGACY_CONFIG = {
|
|||||||
discard_new_per_subject: false,
|
discard_new_per_subject: false,
|
||||||
} as unknown as StreamConfig;
|
} as unknown as StreamConfig;
|
||||||
|
|
||||||
|
interface ConsumerAddConfig {
|
||||||
|
durable_name: string;
|
||||||
|
filter_subjects: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
function streamLimitError(description: string): NatsError {
|
function streamLimitError(description: string): NatsError {
|
||||||
const error = new NatsError('503', '503');
|
const error = new NatsError('503', '503');
|
||||||
error.api_error = {code: 503, err_code: 10077, description};
|
error.api_error = {code: 503, err_code: 10077, description};
|
||||||
@@ -50,12 +56,26 @@ function createQueue(params: {
|
|||||||
existing?: StreamConfig | null;
|
existing?: StreamConfig | null;
|
||||||
updateError?: Error;
|
updateError?: Error;
|
||||||
publish?: (subject: string) => {seq: number};
|
publish?: (subject: string) => {seq: number};
|
||||||
}): {queue: JetStreamWorkerQueue; added: Array<Partial<StreamConfig>>; updated: Array<Partial<StreamConfig>>} {
|
}): {
|
||||||
|
queue: JetStreamWorkerQueue;
|
||||||
|
added: Array<Partial<StreamConfig>>;
|
||||||
|
updated: Array<Partial<StreamConfig>>;
|
||||||
|
consumerAdds: Array<ConsumerAddConfig>;
|
||||||
|
} {
|
||||||
const added: Array<Partial<StreamConfig>> = [];
|
const added: Array<Partial<StreamConfig>> = [];
|
||||||
const updated: Array<Partial<StreamConfig>> = [];
|
const updated: Array<Partial<StreamConfig>> = [];
|
||||||
|
const consumerAdds: Array<ConsumerAddConfig> = [];
|
||||||
const connectionManager = {
|
const connectionManager = {
|
||||||
getJetStreamManager: () =>
|
getJetStreamManager: () =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
|
consumers: {
|
||||||
|
add: (_stream: string, config: ConsumerAddConfig) => {
|
||||||
|
consumerAdds.push(config);
|
||||||
|
return Promise.resolve({});
|
||||||
|
},
|
||||||
|
delete: () => Promise.resolve(true),
|
||||||
|
info: () => Promise.reject(new Error('consumer not found')),
|
||||||
|
},
|
||||||
streams: {
|
streams: {
|
||||||
info: () => {
|
info: () => {
|
||||||
if (!params.existing) {
|
if (!params.existing) {
|
||||||
@@ -83,7 +103,7 @@ function createQueue(params: {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
} as unknown as JetStreamConnectionManager;
|
} as unknown as JetStreamConnectionManager;
|
||||||
return {queue: new JetStreamWorkerQueue(connectionManager), added, updated};
|
return {queue: new JetStreamWorkerQueue(connectionManager), added, updated, consumerAdds};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('jobs stream limits', () => {
|
describe('jobs stream limits', () => {
|
||||||
@@ -143,3 +163,27 @@ describe('jobs stream enqueue shedding', () => {
|
|||||||
await expect(queue.enqueue('extractEmbeds', {})).rejects.toBe(failure);
|
await expect(queue.enqueue('extractEmbeds', {})).rejects.toBe(failure);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('lane consumer filters', () => {
|
||||||
|
it('keeps consuming retired subjects so legacy jobs are drained instead of orphaned', async () => {
|
||||||
|
const {queue, consumerAdds} = createQueue({existing: LEGACY_CONFIG});
|
||||||
|
await queue.ensureConsumers(WORKER_LANES);
|
||||||
|
const lifecycle = consumerAdds.find((config) => config.durable_name === 'workers_lifecycle');
|
||||||
|
expect(lifecycle?.filter_subjects).toContain('jobs.sendSystemDm');
|
||||||
|
expect(lifecycle?.filter_subjects).toContain('jobs.sendScheduledMessage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves lanes without retired tasks filtering only their own subjects', async () => {
|
||||||
|
const {queue, consumerAdds} = createQueue({existing: LEGACY_CONFIG});
|
||||||
|
await queue.ensureConsumers(WORKER_LANES);
|
||||||
|
const unfurl = consumerAdds.find((config) => config.durable_name === 'workers_unfurl');
|
||||||
|
expect(unfurl?.filter_subjects).toEqual(['jobs.extractEmbeds']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never claims the same subject from two lane consumers', async () => {
|
||||||
|
const {queue, consumerAdds} = createQueue({existing: LEGACY_CONFIG});
|
||||||
|
await queue.ensureConsumers(WORKER_LANES);
|
||||||
|
const allSubjects = consumerAdds.flatMap((config) => config.filter_subjects);
|
||||||
|
expect(new Set(allSubjects).size).toBe(allSubjects.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {describe, expect, it} from 'vitest';
|
import {describe, expect, it} from 'vitest';
|
||||||
import {resolveCronSchedulerEnabled, resolveWorkerLanes, WORKER_LANES} from '../WorkerLaneConfig';
|
import {
|
||||||
|
resolveCronSchedulerEnabled,
|
||||||
|
resolveWorkerLanes,
|
||||||
|
validateLaneCompleteness,
|
||||||
|
WORKER_LANES,
|
||||||
|
} from '../WorkerLaneConfig';
|
||||||
|
|
||||||
describe('WorkerLaneConfig', () => {
|
describe('WorkerLaneConfig', () => {
|
||||||
it('returns all lanes in all_lanes mode', () => {
|
it('returns all lanes in all_lanes mode', () => {
|
||||||
@@ -56,6 +61,33 @@ describe('WorkerLaneConfig', () => {
|
|||||||
expect(embedLane[0]!.name).toBe('unfurl');
|
expect(embedLane[0]!.name).toBe('unfurl');
|
||||||
expect(embedLane[0]!.taskTypes).toEqual(['extractEmbeds']);
|
expect(embedLane[0]!.taskTypes).toEqual(['extractEmbeds']);
|
||||||
});
|
});
|
||||||
|
it('keeps the retired scheduled message subject on the lifecycle lane only', () => {
|
||||||
|
const lanes = resolveWorkerLanes({
|
||||||
|
mode: 'all_lanes',
|
||||||
|
laneConcurrencyOverrides: {},
|
||||||
|
});
|
||||||
|
const lifecycleLane = lanes.find((lane) => lane.name === 'lifecycle');
|
||||||
|
expect(lifecycleLane?.retiredTaskTypes).toEqual(['sendScheduledMessage']);
|
||||||
|
expect(lifecycleLane?.taskTypes).not.toContain('sendScheduledMessage');
|
||||||
|
for (const lane of lanes.filter((lane) => lane.name !== 'lifecycle')) {
|
||||||
|
expect(lane.retiredTaskTypes).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('never claims a retired subject from a single_task lane', () => {
|
||||||
|
const lanes = resolveWorkerLanes({
|
||||||
|
mode: 'single_task',
|
||||||
|
taskName: 'processStripeWebhook',
|
||||||
|
laneConcurrencyOverrides: {},
|
||||||
|
});
|
||||||
|
expect(lanes[0]!.retiredTaskTypes).toEqual([]);
|
||||||
|
});
|
||||||
|
it('rejects a registry that brings a retired task name back', () => {
|
||||||
|
const registry = Object.fromEntries(WORKER_LANES.flatMap((lane) => lane.taskTypes).map((task) => [task, () => {}]));
|
||||||
|
expect(() => validateLaneCompleteness(registry)).not.toThrow();
|
||||||
|
expect(() => validateLaneCompleteness({...registry, sendScheduledMessage: () => {}})).toThrow(
|
||||||
|
/Retired tasks registered again: sendScheduledMessage/,
|
||||||
|
);
|
||||||
|
});
|
||||||
it('throws when single_lane mode has no lane configured', () => {
|
it('throws when single_lane mode has no lane configured', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
resolveWorkerLanes({
|
resolveWorkerLanes({
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import type {JsMsg} from 'nats';
|
||||||
|
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
|
||||||
|
import type {IJobLedgerRepository} from '../../jobs/IJobLedgerRepository';
|
||||||
|
import {setInjectedWorkerService} from '../../middleware/ServiceRegistry';
|
||||||
|
import {NoopWorkerService} from '../../test/NoopWorkerService';
|
||||||
|
import {WorkerRunner} from '../WorkerRunner';
|
||||||
|
|
||||||
|
const RETIRED_TASK_TYPE = 'sendScheduledMessage';
|
||||||
|
const RETIRED_REASON = 'task type retired';
|
||||||
|
const LEDGER_JOB_ID = '1509197195776110592';
|
||||||
|
|
||||||
|
const queueStub = {
|
||||||
|
getConnectionManager: () => {
|
||||||
|
throw new Error('WorkerRunner tests never consume messages');
|
||||||
|
},
|
||||||
|
getStreamName: () => 'JOBS',
|
||||||
|
publishToDlq: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ledgerStub = {
|
||||||
|
markDeadletter: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
class TestWorkerRunner extends WorkerRunner {
|
||||||
|
async runJob(taskType: string, msg: JsMsg): Promise<boolean> {
|
||||||
|
return await this.processJob(taskType, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRunner(): TestWorkerRunner {
|
||||||
|
return new TestWorkerRunner({
|
||||||
|
tasks: {},
|
||||||
|
retiredTaskTypes: [RETIRED_TASK_TYPE],
|
||||||
|
queue: queueStub,
|
||||||
|
consumerName: 'workers_lifecycle',
|
||||||
|
laneName: 'lifecycle',
|
||||||
|
ledger: ledgerStub as unknown as IJobLedgerRepository,
|
||||||
|
concurrency: 8,
|
||||||
|
maxDeliver: 25,
|
||||||
|
ackWaitMs: 60000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createJobMessage(taskType: string, payload: Record<string, unknown>) {
|
||||||
|
const envelope = {
|
||||||
|
payload,
|
||||||
|
run_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
max_attempts: 5,
|
||||||
|
priority: 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
seq: 7,
|
||||||
|
subject: `jobs.${taskType}`,
|
||||||
|
redelivered: false,
|
||||||
|
data: new TextEncoder().encode(JSON.stringify(envelope)),
|
||||||
|
info: {deliveryCount: 1},
|
||||||
|
ack: vi.fn(),
|
||||||
|
nak: vi.fn(),
|
||||||
|
term: vi.fn(),
|
||||||
|
working: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Retired worker task types', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
setInjectedWorkerService(new NoopWorkerService());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
queueStub.publishToDlq.mockReset();
|
||||||
|
ledgerStub.markDeadletter.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dead-letters a legacy job and closes its ledger row instead of redelivering it', async () => {
|
||||||
|
const runner = createRunner();
|
||||||
|
const msg = createJobMessage(RETIRED_TASK_TYPE, {
|
||||||
|
userId: '1',
|
||||||
|
scheduledMessageId: '2',
|
||||||
|
__jobId: LEDGER_JOB_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(runner.runJob(RETIRED_TASK_TYPE, msg as unknown as JsMsg)).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(queueStub.publishToDlq).toHaveBeenCalledTimes(1);
|
||||||
|
expect(queueStub.publishToDlq).toHaveBeenCalledWith(
|
||||||
|
RETIRED_TASK_TYPE,
|
||||||
|
{userId: '1', scheduledMessageId: '2'},
|
||||||
|
expect.objectContaining({errorMessage: RETIRED_REASON, lane: 'lifecycle', originalSeq: 7}),
|
||||||
|
);
|
||||||
|
expect(ledgerStub.markDeadletter).toHaveBeenCalledWith(BigInt(LEDGER_JOB_ID), RETIRED_REASON);
|
||||||
|
expect(msg.term).toHaveBeenCalledWith(RETIRED_REASON);
|
||||||
|
expect(msg.nak).not.toHaveBeenCalled();
|
||||||
|
expect(msg.ack).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dead-letters a legacy job that carries no ledger id', async () => {
|
||||||
|
const runner = createRunner();
|
||||||
|
const msg = createJobMessage(RETIRED_TASK_TYPE, {userId: '1', scheduledMessageId: '2'});
|
||||||
|
|
||||||
|
await expect(runner.runJob(RETIRED_TASK_TYPE, msg as unknown as JsMsg)).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(queueStub.publishToDlq).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ledgerStub.markDeadletter).not.toHaveBeenCalled();
|
||||||
|
expect(msg.term).toHaveBeenCalledWith(RETIRED_REASON);
|
||||||
|
expect(msg.nak).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redelivers a retired job when the dead-letter publish fails', async () => {
|
||||||
|
const runner = createRunner();
|
||||||
|
queueStub.publishToDlq.mockRejectedValueOnce(new Error('no responders'));
|
||||||
|
const msg = createJobMessage(RETIRED_TASK_TYPE, {__jobId: LEDGER_JOB_ID});
|
||||||
|
|
||||||
|
await expect(runner.runJob(RETIRED_TASK_TYPE, msg as unknown as JsMsg)).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(ledgerStub.markDeadletter).not.toHaveBeenCalled();
|
||||||
|
expect(msg.term).not.toHaveBeenCalled();
|
||||||
|
expect(msg.nak).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still terminates a task type that was never registered or retired', async () => {
|
||||||
|
const runner = createRunner();
|
||||||
|
const msg = createJobMessage('neverShippedTask', {});
|
||||||
|
|
||||||
|
await expect(runner.runJob('neverShippedTask', msg as unknown as JsMsg)).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(queueStub.publishToDlq).not.toHaveBeenCalled();
|
||||||
|
expect(msg.term).toHaveBeenCalledWith(expect.stringMatching(/unknown task type/));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3101,7 +3101,7 @@ impl From<MessageDbRow> for Message {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use fluxer_svc::transport::InMemoryTransport;
|
use fluxer_svc::transport::{InMemoryTransport, TransportSubscriber, reply_message};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3581,10 +3581,70 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authored_message(message_id: i64) -> Message {
|
||||||
|
decode_postgres_message(json!({
|
||||||
|
"channel_id": {"__fluxer_type": "bigint", "value": "10"},
|
||||||
|
"bucket": 416,
|
||||||
|
"message_id": {"__fluxer_type": "bigint", "value": message_id.to_string()},
|
||||||
|
"author_id": {"__fluxer_type": "bigint", "value": "1472426752046002208"},
|
||||||
|
"content": "kept"
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_string_author_message(message_id: i64) -> Message {
|
||||||
|
decode_postgres_message(json!({
|
||||||
|
"channel_id": {"__fluxer_type": "bigint", "value": "10"},
|
||||||
|
"bucket": 416,
|
||||||
|
"message_id": {"__fluxer_type": "bigint", "value": message_id.to_string()},
|
||||||
|
"author_id": "1472426752046002208",
|
||||||
|
"content": "kept"
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stub_user_service(transport: &InMemoryTransport) -> tokio::task::JoinHandle<()> {
|
||||||
|
let mut subscriber = transport.subscribe("svc.users").await.unwrap();
|
||||||
|
let transport = transport.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(message) = subscriber.next().await {
|
||||||
|
let _ = reply_message(&message, &transport, b"\"NotFound\"").await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn recorded_deletions(deleted: &DeletedMessageKeys) -> Vec<(i64, i32, i64)> {
|
fn recorded_deletions(deleted: &DeletedMessageKeys) -> Vec<(i64, i32, i64)> {
|
||||||
deleted.lock().unwrap().clone()
|
deleted.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn build_path_keeps_authored_rows_from_older_releases() {
|
||||||
|
let deleted = DeletedMessageKeys::default();
|
||||||
|
let shard = recording_shard(&deleted);
|
||||||
|
let users = stub_user_service(&shard.transport).await;
|
||||||
|
let wrapped_id = 1_509_197_195_776_110_592;
|
||||||
|
let legacy_id = 1_509_197_195_776_110_593;
|
||||||
|
|
||||||
|
let responses = shard
|
||||||
|
.build_api_responses_from_messages(
|
||||||
|
vec![
|
||||||
|
authored_message(wrapped_id),
|
||||||
|
legacy_string_author_message(legacy_id),
|
||||||
|
],
|
||||||
|
build_options(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
users.abort();
|
||||||
|
|
||||||
|
assert_eq!(responses.len(), 2);
|
||||||
|
assert_eq!(responses[0].id, wrapped_id.to_string());
|
||||||
|
assert_eq!(responses[1].id, legacy_id.to_string());
|
||||||
|
assert_eq!(responses[0].author.id, "1472426752046002208");
|
||||||
|
assert_eq!(responses[1].author.id, "1472426752046002208");
|
||||||
|
assert!(recorded_deletions(&deleted).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn build_path_reaps_orphaned_messages() {
|
async fn build_path_reaps_orphaned_messages() {
|
||||||
let deleted = DeletedMessageKeys::default();
|
let deleted = DeletedMessageKeys::default();
|
||||||
|
|||||||
Reference in New Issue
Block a user