chore: cleanup
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user