File size: 5,514 Bytes
56838f4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | import { convexTest } from "convex-test";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { internal } from "../_generated/api";
import schema from "../schema";
const modules = import.meta.glob("../**/*.ts");
const pruneFns = (internal as any).apiPlanLimitNotices;
const NOW = 1_800_000_000_000;
const DAY = 86_400_000;
function notice(overrides: Record<string, unknown>) {
return {
userId: "u",
planKey: "api_starter",
dimension: "api_daily_requests",
state: "over_limit",
windowKey: "w",
firstSeenAt: NOW,
lastSeenAt: NOW,
usage: 1_500,
limit: 1_000,
usageRatio: 1.5,
current: false,
emailStatus: "sent",
ctaKind: "contact_support",
...overrides,
};
}
function makeRollup(overrides: Record<string, unknown>) {
return {
userId: "u",
planKey: "api_starter",
dimension: "api_daily_requests",
windowKey: "w",
windowStart: NOW,
windowEnd: NOW + DAY,
limit: 1_000,
usage: 900,
usageRatio: 0.9,
source: "test",
sourceFreshAt: NOW,
computedAt: NOW,
...overrides,
};
}
describe("api plan-limit retention prune", () => {
// The prune self-schedules its continuation via ctx.scheduler.runAfter(0)
// whenever a full batch is deleted. Under fake timers that continuation is
// queued but never fires on its own (convex-test can't cleanly execute a
// self-scheduling mutation's continuation), so tests drive the drain by
// re-invoking and discard the queued callbacks on teardown.
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
test("prunes aged rollups + superseded notices, keeps recent and live rows", async () => {
const t = convexTest(schema, modules);
await t.run(async (ctx) => {
// Old + superseded -> pruned.
await ctx.db.insert("apiPlanLimitNotices", notice({ windowKey: "old", lastSeenAt: NOW - 200 * DAY }));
// Old but LIVE (current:true) -> kept; an active notice is never pruned.
await ctx.db.insert("apiPlanLimitNotices", notice({ windowKey: "old-live", lastSeenAt: NOW - 200 * DAY, current: true }));
// Recent + superseded -> kept.
await ctx.db.insert("apiPlanLimitNotices", notice({ windowKey: "recent", lastSeenAt: NOW - DAY }));
await ctx.db.insert("apiUsageRollups", makeRollup({ windowKey: "old", computedAt: NOW - 200 * DAY }));
await ctx.db.insert("apiUsageRollups", makeRollup({ windowKey: "recent", computedAt: NOW - DAY }));
});
const result = await t.mutation(pruneFns.pruneApiPlanLimitData, { now: NOW });
expect(result).toMatchObject({ noticesDeleted: 1, rollupsDeleted: 1 });
const notices = await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect());
const rollups = await t.run((ctx) => ctx.db.query("apiUsageRollups").collect());
expect(notices.map((n) => n.windowKey).sort()).toEqual(["old-live", "recent"]);
expect(rollups.map((r) => r.windowKey)).toEqual(["recent"]);
});
test("deletes at most `limit` per run and drains the rest on the next run", async () => {
const t = convexTest(schema, modules);
await t.run(async (ctx) => {
for (let i = 0; i < 3; i++) {
await ctx.db.insert("apiPlanLimitNotices", notice({ windowKey: `old-${i}`, lastSeenAt: NOW - 200 * DAY }));
}
});
const first = await t.mutation(pruneFns.pruneApiPlanLimitData, { now: NOW, limit: 2 });
expect(first.noticesDeleted).toBe(2);
expect(await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect())).toHaveLength(1);
const second = await t.mutation(pruneFns.pruneApiPlanLimitData, { now: NOW, limit: 2 });
expect(second.noticesDeleted).toBe(1);
expect(await t.run((ctx) => ctx.db.query("apiPlanLimitNotices").collect())).toHaveLength(0);
});
test("self-reschedules until the whole aged backlog drains", async () => {
const t = convexTest(schema, modules);
// 5 aged rollups, drained 2 at a time. The mutation reschedules itself via
// runAfter(0) whenever a full batch is deleted; we drive that chain
// deterministically by re-invoking while `rescheduled` is true. This proves
// the reschedule DECISION and that it TERMINATES once drained -- so a backlog
// larger than one batch can't outlive a single cron tick.
await t.run(async (ctx) => {
for (let i = 0; i < 5; i++) {
await ctx.db.insert("apiUsageRollups", makeRollup({ windowKey: `old-${i}`, computedAt: NOW - 200 * DAY }));
}
});
let res = await t.mutation(pruneFns.pruneApiPlanLimitData, { now: NOW, limit: 2 });
expect(res).toMatchObject({ rollupsDeleted: 2, rescheduled: true });
let guard = 0;
while (res.rescheduled) {
if (++guard > 10) throw new Error("prune did not terminate");
res = await t.mutation(pruneFns.pruneApiPlanLimitData, { now: NOW, limit: 2 });
}
expect(res.rescheduled).toBe(false);
expect(await t.run((ctx) => ctx.db.query("apiUsageRollups").collect())).toHaveLength(0);
});
test("does not reschedule when the backlog fits in one batch", async () => {
const t = convexTest(schema, modules);
await t.run(async (ctx) => {
await ctx.db.insert("apiUsageRollups", makeRollup({ windowKey: "old", computedAt: NOW - 200 * DAY }));
});
const result = await t.mutation(pruneFns.pruneApiPlanLimitData, { now: NOW, limit: 500 });
expect(result).toMatchObject({ rollupsDeleted: 1, rescheduled: false });
});
});
|