chore: cleanup

This commit is contained in:
Charlie Lamb
2026-04-24 17:12:24 +01:00
parent 33ecaebb7b
commit f15bbde76a
7 changed files with 120 additions and 55 deletions

View File

@@ -24,7 +24,7 @@ export const queueTrack = async ({
jobName: JobName.Track, jobName: JobName.Track,
queueUrl, queueUrl,
messageGroupId: `${ctx.org.id}:${ctx.env}:${body.customer_id}:${body.entity_id ?? "none"}`, messageGroupId: `${ctx.org.id}:${ctx.env}:${body.customer_id}:${body.entity_id ?? "none"}`,
messageDeduplicationId: body.idempotency_key || ctx.id, messageDeduplicationId: ctx.id,
payload: { payload: {
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,

View File

@@ -7,6 +7,7 @@ import {
type TrackResponseV3, type TrackResponseV3,
} from "@autumn/shared"; } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { RedisUnavailableError } from "@/external/redis/utils/errors.js";
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
import { import {
RedisDeductionError, RedisDeductionError,
@@ -57,7 +58,11 @@ export const handleRedisTrackErrorV3 = async ({
} }
if (error.isRedisUnavailable()) { if (error.isRedisUnavailable()) {
throw error; throw new RedisUnavailableError({
source: "runTrackV3",
reason: "other",
cause: error,
});
} }
if (error.shouldFallback()) { if (error.shouldFallback()) {

View File

@@ -26,6 +26,7 @@ import { processMessage, type SqsJob } from "./processMessage.js";
// ============ Shared State ============ // ============ Shared State ============
let isRunning = true; let isRunning = true;
const abortControllers = new Set<AbortController>(); const abortControllers = new Set<AbortController>();
export const getAbortControllerCountForTesting = () => abortControllers.size;
// Process recycling — exit after processing this many messages to prevent memory leaks // Process recycling — exit after processing this many messages to prevent memory leaks
const MAX_MESSAGES_BEFORE_RECYCLE = 50_000; const MAX_MESSAGES_BEFORE_RECYCLE = 50_000;
@@ -78,6 +79,11 @@ export const startPollingLoop = async ({
const prefix = logPrefix({ queueUrl }); const prefix = logPrefix({ queueUrl });
let abortController = new AbortController(); let abortController = new AbortController();
abortControllers.add(abortController); abortControllers.add(abortController);
const replaceAbortController = () => {
abortControllers.delete(abortController);
abortController = new AbortController();
abortControllers.add(abortController);
};
const alertZeroMessages = () => { const alertZeroMessages = () => {
const minutes = consecutiveZeroMessageIntervals; const minutes = consecutiveZeroMessageIntervals;
@@ -259,8 +265,7 @@ export const startPollingLoop = async ({
`${prefix} ${consecutiveEmptyPolls} consecutive empty polls - recreating SQS client`, `${prefix} ${consecutiveEmptyPolls} consecutive empty polls - recreating SQS client`,
); );
consecutiveEmptyPolls = 0; consecutiveEmptyPolls = 0;
abortController = new AbortController(); replaceAbortController();
abortControllers.add(abortController);
return recreateSqsClientFn(); return recreateSqsClientFn();
} }
@@ -285,8 +290,7 @@ export const startPollingLoop = async ({
if (consecutiveEmptyPolls >= EMPTY_POLL_THRESHOLD) { if (consecutiveEmptyPolls >= EMPTY_POLL_THRESHOLD) {
console.warn(`${prefix} Repeated errors - recreating SQS client`); console.warn(`${prefix} Repeated errors - recreating SQS client`);
consecutiveEmptyPolls = 0; consecutiveEmptyPolls = 0;
abortController = new AbortController(); replaceAbortController();
abortControllers.add(abortController);
await new Promise((resolve) => setTimeout(resolve, 5000)); await new Promise((resolve) => setTimeout(resolve, 5000));
return recreateSqsClientFn(); return recreateSqsClientFn();
} }

View File

@@ -1,5 +1,8 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { startPollingLoop } from "@/queue/initWorkers.js"; import {
getAbortControllerCountForTesting,
startPollingLoop,
} from "@/queue/initWorkers.js";
const originalSetTimeout = globalThis.setTimeout; const originalSetTimeout = globalThis.setTimeout;
@@ -86,4 +89,35 @@ describe("startPollingLoop", () => {
expect(shouldPollCalls).toBe(1); expect(shouldPollCalls).toBe(1);
expect(sendCalls).toBe(1); expect(sendCalls).toBe(1);
}); });
test("does not leak abort controllers when the SQS client is recreated", async () => {
let sendCalls = 0;
let recreateCalls = 0;
const makeClient = (abortAfterRecreate: boolean) =>
({
send: async () => {
sendCalls++;
if (abortAfterRecreate) {
throw makeAbortError();
}
return { Messages: [] };
},
}) as never;
await startPollingLoop({
db: {} as never,
queueUrl: "https://sqs.eu-west-1.amazonaws.com/123/track.fifo",
isFifo: true,
getSqsClientFn: () => makeClient(false),
recreateSqsClientFn: () => {
recreateCalls++;
return makeClient(true);
},
shouldPoll: () => true,
});
expect(recreateCalls).toBe(1);
expect(sendCalls).toBeGreaterThan(9);
expect(getAbortControllerCountForTesting()).toBe(0);
});
}); });

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"; import { beforeEach, describe, expect, mock, test } from "bun:test";
import { ApiVersion, ApiVersionClass, AppEnv } from "@autumn/shared"; import { ApiVersion, ApiVersionClass, AppEnv } from "@autumn/shared";
import { RedisUnavailableError } from "@/external/redis/utils/errors.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { import {
RedisDeductionError, RedisDeductionError,
@@ -36,7 +37,7 @@ describe("handleRedisTrackErrorV3", () => {
mockState.postgresCalls = []; mockState.postgresCalls = [];
}); });
test("rethrows when Redis is unavailable", async () => { test("normalizes Redis unavailable to RedisUnavailableError", async () => {
const error = new RedisDeductionError({ const error = new RedisDeductionError({
message: "Redis not ready for deduction", message: "Redis not ready for deduction",
code: RedisDeductionErrorCode.RedisUnavailable, code: RedisDeductionErrorCode.RedisUnavailable,
@@ -54,7 +55,11 @@ describe("handleRedisTrackErrorV3", () => {
fullSubject: {} as never, fullSubject: {} as never,
featureDeductions: [], featureDeductions: [],
}), }),
).rejects.toBe(error); ).rejects.toMatchObject({
name: "RedisUnavailableError",
source: "runTrackV3",
reason: "other",
} satisfies Partial<RedisUnavailableError>);
expect(mockState.postgresCalls).toHaveLength(0); expect(mockState.postgresCalls).toHaveLength(0);
}); });

View File

@@ -1,23 +1,20 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { import {
ApiVersion, ApiVersion,
ApiVersionClass, ApiVersionClass,
AppEnv, AppEnv,
type TrackResponseV3,
} from "@autumn/shared"; } from "@autumn/shared";
import { RedisUnavailableError } from "@/external/redis/utils/errors.js"; import { RedisUnavailableError } from "@/external/redis/utils/errors.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getSqsClient } from "@/queue/initSqs.js";
const mockState = { const mockState = {
queueCalls: [] as Record<string, unknown>[], queueCommands: [] as Record<string, unknown>[],
queueError: null as Error | null,
originalSend: null as ReturnType<typeof getSqsClient>["send"] | null,
runTrackV2Calls: [] as Record<string, unknown>[], runTrackV2Calls: [] as Record<string, unknown>[],
runTrackV3Calls: [] as Record<string, unknown>[], runTrackV3Calls: [] as Record<string, unknown>[],
v3Error: null as unknown, v3Error: null as unknown,
queuedResponse: {
customer_id: "cus_123",
value: 2,
balance: null,
} as TrackResponseV3 | null,
}; };
mock.module("@/internal/balances/track/runTrackV2.js", () => ({ mock.module("@/internal/balances/track/runTrackV2.js", () => ({
@@ -35,13 +32,6 @@ mock.module("@/internal/balances/track/v3/runTrackV3.js", () => ({
}, },
})); }));
mock.module("@/internal/balances/track/utils/queueTrack.js", () => ({
queueTrack: async (args: Record<string, unknown>) => {
mockState.queueCalls.push(args);
return mockState.queuedResponse;
},
}));
mock.module("@/external/redis/initUtils/redisV2Availability.js", () => ({ mock.module("@/external/redis/initUtils/redisV2Availability.js", () => ({
shouldUseRedisV2: () => true, shouldUseRedisV2: () => true,
})); }));
@@ -74,15 +64,24 @@ const body = {
describe("track queue fallback", () => { describe("track queue fallback", () => {
beforeEach(() => { beforeEach(() => {
mockState.queueCalls = []; mockState.queueCommands = [];
mockState.queueError = null;
mockState.runTrackV2Calls = []; mockState.runTrackV2Calls = [];
mockState.runTrackV3Calls = []; mockState.runTrackV3Calls = [];
mockState.v3Error = null; mockState.v3Error = null;
mockState.queuedResponse = { process.env.TRACK_SQS_QUEUE_URL =
customer_id: "cus_123", "https://sqs.eu-west-1.amazonaws.com/123456789012/track-dev.fifo";
value: 2,
balance: null, const sqsClient = getSqsClient();
}; mockState.originalSend = sqsClient.send.bind(sqsClient);
sqsClient.send = (async (command: { input: Record<string, unknown> }) => {
if (mockState.queueError) {
throw mockState.queueError;
}
mockState.queueCommands.push(command.input);
return {};
}) as typeof sqsClient.send;
}); });
test("queues track when rollout path hits a retryable Redis failure", async () => { test("queues track when rollout path hits a retryable Redis failure", async () => {
@@ -99,16 +98,17 @@ describe("track queue fallback", () => {
expect(mockState.runTrackV3Calls).toHaveLength(1); expect(mockState.runTrackV3Calls).toHaveLength(1);
expect(mockState.runTrackV2Calls).toHaveLength(0); expect(mockState.runTrackV2Calls).toHaveLength(0);
expect(mockState.queueCalls).toHaveLength(1); expect(mockState.queueCommands).toHaveLength(1);
expect(mockState.queueCalls[0]).toMatchObject({ expect(mockState.queueCommands[0]).toMatchObject({
body: { QueueUrl:
customer_id: "cus_123", "https://sqs.eu-west-1.amazonaws.com/123456789012/track-dev.fifo",
feature_id: "messages", });
}, expect(response).toEqual({
customer_id: "cus_123",
entity_id: undefined,
value: 2,
balance: null,
}); });
if (!mockState.queuedResponse) throw new Error("expected queued response");
expect(response).toEqual(mockState.queuedResponse);
}); });
test("throws retryable Redis failure when queue fallback is unavailable", async () => { test("throws retryable Redis failure when queue fallback is unavailable", async () => {
@@ -117,7 +117,7 @@ describe("track queue fallback", () => {
reason: "timeout", reason: "timeout",
}); });
mockState.v3Error = error; mockState.v3Error = error;
mockState.queuedResponse = null; mockState.queueError = new Error("sqs unavailable");
await expect( await expect(
runTrackWithRollout({ runTrackWithRollout({
@@ -127,6 +127,13 @@ describe("track queue fallback", () => {
}), }),
).rejects.toBe(error); ).rejects.toBe(error);
expect(mockState.queueCalls).toHaveLength(1); expect(mockState.queueCommands).toHaveLength(0);
});
afterEach(() => {
const sqsClient = getSqsClient();
if (mockState.originalSend) {
sqsClient.send = mockState.originalSend;
}
}); });
}); });

View File

@@ -1,17 +1,14 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { ApiVersion, ApiVersionClass, AppEnv } from "@autumn/shared"; import { ApiVersion, ApiVersionClass, AppEnv } from "@autumn/shared";
import type { SQSClient } from "@aws-sdk/client-sqs";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getSqsClient } from "@/queue/initSqs.js";
const mockState = { const mockState = {
queueCalls: [] as Record<string, unknown>[], queueCommands: [] as Record<string, unknown>[],
originalSend: null as null | SQSClient["send"],
}; };
mock.module("@/queue/queueUtils.js", () => ({
addTaskToQueue: async (args: Record<string, unknown>) => {
mockState.queueCalls.push(args);
},
}));
mock.module("@/internal/balances/track/utils/getQueuedTrackResponse.js", () => ({ mock.module("@/internal/balances/track/utils/getQueuedTrackResponse.js", () => ({
getQueuedTrackResponse: () => ({ getQueuedTrackResponse: () => ({
customer_id: "cus_123", customer_id: "cus_123",
@@ -26,9 +23,15 @@ describe("queueTrack", () => {
const originalTrackQueueUrl = process.env.TRACK_SQS_QUEUE_URL; const originalTrackQueueUrl = process.env.TRACK_SQS_QUEUE_URL;
beforeEach(() => { beforeEach(() => {
mockState.queueCalls = []; mockState.queueCommands = [];
process.env.TRACK_SQS_QUEUE_URL = process.env.TRACK_SQS_QUEUE_URL =
"https://sqs.eu-west-1.amazonaws.com/123456789012/track-dev.fifo"; "https://sqs.eu-west-1.amazonaws.com/123456789012/track-dev.fifo";
const sqsClient = getSqsClient();
mockState.originalSend = sqsClient.send.bind(sqsClient);
sqsClient.send = (async (command: { input: Record<string, unknown> }) => {
mockState.queueCommands.push(command.input);
return {};
}) as typeof sqsClient.send;
}); });
test("queues track with request identity and entity-scoped grouping", async () => { test("queues track with request identity and entity-scoped grouping", async () => {
@@ -52,13 +55,16 @@ describe("queueTrack", () => {
}, },
}); });
expect(mockState.queueCalls).toHaveLength(1); expect(mockState.queueCommands).toHaveLength(1);
expect(mockState.queueCalls[0]).toMatchObject({ expect(mockState.queueCommands[0]).toMatchObject({
queueUrl: QueueUrl:
"https://sqs.eu-west-1.amazonaws.com/123456789012/track-dev.fifo", "https://sqs.eu-west-1.amazonaws.com/123456789012/track-dev.fifo",
messageGroupId: "org_123:sandbox:cus_123:ent_123", MessageGroupId: "org_123:sandbox:cus_123:ent_123",
messageDeduplicationId: "req_123", MessageDeduplicationId: "req_123",
payload: { });
expect(JSON.parse(mockState.queueCommands[0]?.MessageBody as string)).toMatchObject({
name: "track",
data: {
orgId: "org_123", orgId: "org_123",
env: AppEnv.Sandbox, env: AppEnv.Sandbox,
customerId: "cus_123", customerId: "cus_123",
@@ -70,6 +76,10 @@ describe("queueTrack", () => {
}); });
afterEach(() => { afterEach(() => {
if (mockState.originalSend) {
const sqsClient = getSqsClient();
sqsClient.send = mockState.originalSend as typeof sqsClient.send;
}
process.env.TRACK_SQS_QUEUE_URL = originalTrackQueueUrl; process.env.TRACK_SQS_QUEUE_URL = originalTrackQueueUrl;
}); });
}); });