Email Automation V1.5 (Fixed the Automated Email Schedule-Sender and the Email Schedule Fairness Problem)

This commit is contained in:
AddarshKS
2026-07-17 01:28:21 +00:00
parent bf99da4c2e
commit fa2dc67165
14 changed files with 1146 additions and 686 deletions
@@ -0,0 +1,17 @@
CREATE TABLE "AutomationSchedulerState" (
"key" TEXT NOT NULL,
"completedTenancyCursor" UUID,
"activeTenancyId" UUID,
"completedRuleCursor" TEXT,
"activeRuleId" TEXT,
"nextSubjectCursor" UUID,
"leaseOwner" UUID,
"leaseExpiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutomationSchedulerState_pkey" PRIMARY KEY ("key")
);
INSERT INTO "AutomationSchedulerState" ("key", "updatedAt")
VALUES ('usage-email-v1', CURRENT_TIMESTAMP);
@@ -0,0 +1,20 @@
import type { Sql } from "postgres";
import { expect } from "vitest";
export const postMigration = async (sql: Sql) => {
const stateRows = await sql`
SELECT "key", "completedTenancyCursor", "activeTenancyId", "completedRuleCursor",
"activeRuleId", "nextSubjectCursor", "leaseOwner", "leaseExpiresAt"
FROM "AutomationSchedulerState"
`;
expect(Array.from(stateRows)).toEqual([{
key: "usage-email-v1",
completedTenancyCursor: null,
activeTenancyId: null,
completedRuleCursor: null,
activeRuleId: null,
nextSubjectCursor: null,
leaseOwner: null,
leaseExpiresAt: null,
}]);
};
+17
View File
@@ -1187,6 +1187,23 @@ model AutomationRuleExecutionState {
@@index([tenancyId, ruleId, lastTriggeredAt], name: "AutomationRuleExecutionState_tenancy_rule_triggered_idx")
}
model AutomationSchedulerState {
key String @id
completedTenancyCursor String? @db.Uuid
activeTenancyId String? @db.Uuid
completedRuleCursor String?
activeRuleId String?
// Continuation cursor passed to the next source-adapter page.
nextSubjectCursor String? @db.Uuid
leaseOwner String? @db.Uuid
leaseExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Conversation {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { maxDuration, parseAutomationScheduleBound } from "./route";
describe("automation schedule route bounds", () => {
it("keeps the work budget below the route runtime limit", () => {
expect(maxDuration).toBe(60);
expect(parseAutomationScheduleBound("45000", "max_duration_ms", 45_000)).toBe(45_000);
});
it("allows omitted and lower positive integer bounds", () => {
expect(parseAutomationScheduleBound(undefined, "limit", 100)).toBeUndefined();
expect(parseAutomationScheduleBound("25", "limit", 100)).toBe(25);
});
it.each(["0", "101", "1.5", "01", "not-a-number"])("rejects an invalid internal bound: %s", (value) => {
expect(() => parseAutomationScheduleBound(value, "limit", 100))
.toThrowError("limit must be an integer between 1 and 100");
});
});
@@ -1,8 +1,9 @@
import {
discoverEnabledScheduledAutomationRules,
enqueueScheduledAutomationRuns,
normalizeScheduledAutomationDiscoveryLimit,
normalizeScheduledAutomationRunPageLimit,
runScheduledAutomations,
scheduledAutomationDiscoveryLimit,
scheduledAutomationMaxPages,
scheduledAutomationRunPageLimit,
scheduledAutomationWorkBudgetMs,
} from "@/lib/automations/scheduler";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupBoolean, yupNumber, yupObject, yupString, yupTuple } from "@hexclave/shared/dist/schema-fields";
@@ -11,11 +12,12 @@ import { StatusError } from "@hexclave/shared/dist/utils/errors";
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const maxDuration = 60;
export const GET = createSmartRouteHandler({
metadata: {
summary: "Schedule automation rule runs",
description: "Internal endpoint invoked by cron to enqueue bounded scheduled automation rule runs.",
summary: "Run scheduled automation rules",
description: "Internal endpoint invoked by cron to execute bounded scheduled automation work.",
tags: ["Automations"],
hidden: true,
},
@@ -26,9 +28,10 @@ export const GET = createSmartRouteHandler({
authorization: yupTuple([yupString().defined()]).optional(),
}).defined(),
query: yupObject({
cursor: yupString().optional(),
max_tenancies: yupString().optional(),
limit: yupString().optional(),
max_pages: yupString().optional(),
max_duration_ms: yupString().optional(),
}).defined(),
}),
response: yupObject({
@@ -36,10 +39,14 @@ export const GET = createSmartRouteHandler({
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
ok: yupBoolean().defined(),
status: yupString().oneOf(["ran", "lease-held"]).defined(),
tenancies_scanned: yupNumber().integer().defined(),
rules_found: yupNumber().integer().defined(),
runs_enqueued: yupNumber().integer().defined(),
next_cursor: yupString().nullable().defined(),
rules_processed: yupNumber().integer().defined(),
pages_processed: yupNumber().integer().defined(),
evaluated_count: yupNumber().integer().defined(),
sent_count: yupNumber().integer().defined(),
suppressed_count: yupNumber().integer().defined(),
cycle_completed: yupBoolean().defined(),
}).defined(),
}),
handler: async ({ auth, headers, query }) => {
@@ -49,14 +56,11 @@ export const GET = createSmartRouteHandler({
throw new StatusError(401, "Unauthorized");
}
const discovery = await discoverEnabledScheduledAutomationRules({
limit: parseOptionalPositiveInteger(query.max_tenancies, "max_tenancies"),
cursor: query.cursor,
});
const enqueueResult = await enqueueScheduledAutomationRuns({
targets: discovery.targets,
limit: parseOptionalPositiveInteger(query.limit, "limit"),
scheduledAt: new Date(),
const result = await runScheduledAutomations({
maxTenancies: parseAutomationScheduleBound(query.max_tenancies, "max_tenancies", scheduledAutomationDiscoveryLimit),
pageLimit: parseAutomationScheduleBound(query.limit, "limit", scheduledAutomationRunPageLimit),
maxPages: parseAutomationScheduleBound(query.max_pages, "max_pages", scheduledAutomationMaxPages),
workBudgetMs: parseAutomationScheduleBound(query.max_duration_ms, "max_duration_ms", scheduledAutomationWorkBudgetMs),
});
return {
@@ -64,24 +68,26 @@ export const GET = createSmartRouteHandler({
bodyType: "json",
body: {
ok: true,
tenancies_scanned: discovery.scannedTenancyCount,
rules_found: discovery.targets.length,
runs_enqueued: enqueueResult.enqueuedCount,
next_cursor: discovery.nextCursor,
status: result.status,
tenancies_scanned: result.tenanciesScanned,
rules_processed: result.rulesProcessed,
pages_processed: result.pagesProcessed,
evaluated_count: result.evaluatedCount,
sent_count: result.sentCount,
suppressed_count: result.suppressedCount,
cycle_completed: result.cycleCompleted,
},
};
},
});
function parseOptionalPositiveInteger(value: string | null | undefined, label: "max_tenancies" | "limit") {
export function parseAutomationScheduleBound(value: string | null | undefined, label: string, maximum: number) {
if (value == null || value === "") {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1 || String(parsed) !== value) {
throw new StatusError(400, `${label} must be a positive integer`);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > maximum || String(parsed) !== value) {
throw new StatusError(400, `${label} must be an integer between 1 and ${maximum}`);
}
return label === "max_tenancies"
? normalizeScheduledAutomationDiscoveryLimit(parsed)
: normalizeScheduledAutomationRunPageLimit(parsed);
return parsed;
}
@@ -1,82 +0,0 @@
import {
normalizeScheduledAutomationRunPageLimit,
runScheduledAutomationRulePage,
} from "@/lib/automations/scheduler";
import { parseAutomationScheduledAtMillis } from "@/lib/automations/scheduled-at";
import { ensureUpstashSignature } from "@/lib/upstash";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupBoolean, yupNumber, yupObject, yupString, yupTuple } from "@hexclave/shared/dist/schema-fields";
export const POST = createSmartRouteHandler({
metadata: {
summary: "Run one scheduled automation rule page",
description: "Receives QStash work for a single tenancy/rule automation run page.",
tags: ["Automations"],
hidden: true,
},
request: yupObject({
headers: yupObject({
"upstash-signature": yupTuple([yupString().defined()]).defined(),
}).defined(),
method: yupString().oneOf(["POST"]).defined(),
body: yupObject({
tenancyId: yupString().defined(),
ruleId: yupString().defined(),
cursor: yupString().nullable().optional(),
limit: yupNumber().integer().min(1).max(100).optional(),
scheduledAtMillis: yupNumber().integer().optional(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
ok: yupBoolean().defined(),
status: yupString().oneOf(["ran", "skipped"]).defined(),
skipped_reason: yupString().oneOf(["tenancy-not-found", "rule-not-found", "rule-disabled"]).optional(),
evaluated_count: yupNumber().integer().optional(),
sent_count: yupNumber().integer().optional(),
suppressed_count: yupNumber().integer().optional(),
next_cursor: yupString().nullable().optional(),
enqueued_continuation: yupBoolean().optional(),
}).defined(),
}),
handler: async ({ body }, fullReq) => {
await ensureUpstashSignature(fullReq);
const scheduledAt = parseAutomationScheduledAtMillis(body.scheduledAtMillis, "scheduledAtMillis");
const result = await runScheduledAutomationRulePage({
tenancyId: body.tenancyId,
ruleId: body.ruleId,
cursor: body.cursor,
limit: normalizeScheduledAutomationRunPageLimit(body.limit),
scheduledAt,
now: new Date(),
});
if (result.status === "skipped") {
return {
statusCode: 200,
bodyType: "json",
body: {
ok: true,
status: result.status,
skipped_reason: result.reason,
},
};
}
return {
statusCode: 200,
bodyType: "json",
body: {
ok: true,
status: result.status,
evaluated_count: result.result.evaluatedCount,
sent_count: result.result.sentCount,
suppressed_count: result.result.suppressedCount,
next_cursor: result.result.nextCursor,
enqueued_continuation: result.enqueuedContinuation,
},
};
},
});
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import {
automationSchedulerStateKey,
createPrismaAutomationSchedulerStateStore,
type AutomationSchedulerCheckpoint,
} from "./scheduler-state";
const emptyCheckpoint: AutomationSchedulerCheckpoint = {
completedTenancyCursor: null,
activeTenancyId: null,
completedRuleCursor: null,
activeRuleId: null,
nextSubjectCursor: null,
};
function createFakePrisma(options: { missing?: boolean } = {}) {
let state = options.missing ? null : {
key: automationSchedulerStateKey,
...emptyCheckpoint,
leaseOwner: null as string | null,
leaseExpiresAt: null as Date | null,
};
const matchesWhere = (where: any) => {
const currentState = state;
if (currentState === null || where.key !== currentState.key) return false;
if (where.leaseOwner !== undefined && where.leaseOwner !== currentState.leaseOwner) return false;
if (where.leaseExpiresAt?.gt !== undefined && !(currentState.leaseExpiresAt !== null && currentState.leaseExpiresAt > where.leaseExpiresAt.gt)) return false;
if (where.OR !== undefined) {
return where.OR.some((condition: any) => {
if (condition.leaseOwner === null) return currentState.leaseOwner === null;
if (condition.leaseExpiresAt?.lte !== undefined) {
return currentState.leaseExpiresAt !== null && currentState.leaseExpiresAt <= condition.leaseExpiresAt.lte;
}
return false;
});
}
return true;
};
return {
prisma: {
automationSchedulerState: {
async updateMany({ where, data }: any) {
if (!matchesWhere(where)) return { count: 0 };
state = { ...state!, ...data };
return { count: 1 };
},
async findUnique() {
return state === null ? null : { ...state };
},
},
} as any,
getState: () => state,
};
}
function createClock(initial: string) {
let current = new Date(initial);
return {
now: () => new Date(current),
advance: (milliseconds: number) => {
current = new Date(current.getTime() + milliseconds);
},
};
}
describe("Prisma automation scheduler state store", () => {
it("allows only one concurrent lease acquisition", async () => {
const fake = createFakePrisma();
const clock = createClock("2026-07-17T12:00:00.000Z");
let ownerSequence = 0;
const store = createPrismaAutomationSchedulerStateStore({
prisma: fake.prisma,
now: clock.now,
ownerId: () => `00000000-0000-4000-8000-${String(++ownerSequence).padStart(12, "0")}`,
});
const [first, second] = await Promise.all([store.acquire(), store.acquire()]);
expect([first, second].filter((lease) => lease !== null)).toHaveLength(1);
expect([first, second].filter((lease) => lease === null)).toHaveLength(1);
});
it("reclaims an expired lease and fences the stale owner", async () => {
const fake = createFakePrisma();
const clock = createClock("2026-07-17T12:00:00.000Z");
const firstStore = createPrismaAutomationSchedulerStateStore({
prisma: fake.prisma,
now: clock.now,
ownerId: () => "00000000-0000-4000-8000-000000000001",
leaseDurationMs: 1_000,
renewalThresholdMs: 250,
});
const firstLease = await firstStore.acquire() ?? undefined;
expect(firstLease).toBeDefined();
clock.advance(1_001);
const secondStore = createPrismaAutomationSchedulerStateStore({
prisma: fake.prisma,
now: clock.now,
ownerId: () => "00000000-0000-4000-8000-000000000002",
leaseDurationMs: 1_000,
renewalThresholdMs: 250,
});
const secondLease = await secondStore.acquire() ?? undefined;
expect(secondLease?.ownerId).toBe("00000000-0000-4000-8000-000000000002");
await expect(firstLease!.saveCheckpoint({
...emptyCheckpoint,
activeTenancyId: "00000000-0000-4000-8000-000000000010",
})).rejects.toThrow("lease was lost or expired");
await expect(firstLease!.renewIfNeeded()).rejects.toThrow("lease was lost or expired");
await expect(firstLease!.release()).rejects.toThrow("lease was lost or expired");
await secondLease!.saveCheckpoint({
...emptyCheckpoint,
activeTenancyId: "00000000-0000-4000-8000-000000000020",
});
expect(fake.getState()?.activeTenancyId).toBe("00000000-0000-4000-8000-000000000020");
await secondLease!.release();
expect(fake.getState()?.leaseOwner).toBeNull();
});
it("renews with injected duration values when the threshold is reached", async () => {
const fake = createFakePrisma();
const clock = createClock("2026-07-17T12:00:00.000Z");
const lease = await createPrismaAutomationSchedulerStateStore({
prisma: fake.prisma,
now: clock.now,
ownerId: () => "00000000-0000-4000-8000-000000000001",
leaseDurationMs: 120_000,
renewalThresholdMs: 30_000,
}).acquire();
clock.advance(89_999);
await lease!.renewIfNeeded();
expect(fake.getState()?.leaseExpiresAt).toEqual(new Date("2026-07-17T12:02:00.000Z"));
clock.advance(2);
await lease!.renewIfNeeded();
expect(fake.getState()?.leaseExpiresAt).toEqual(new Date("2026-07-17T12:03:30.001Z"));
});
it("fails loudly when the migration-seeded singleton is missing", async () => {
const fake = createFakePrisma({ missing: true });
const store = createPrismaAutomationSchedulerStateStore({
prisma: fake.prisma,
ownerId: () => "00000000-0000-4000-8000-000000000001",
});
await expect(store.acquire()).rejects.toThrow("Apply the scheduler-state migration");
});
});
@@ -0,0 +1,176 @@
import { randomUUID } from "crypto";
import type { PrismaClientTransaction } from "@/prisma-client";
export const automationSchedulerStateKey = "usage-email-v1";
export const automationSchedulerLeaseDurationMs = 120_000;
export const automationSchedulerLeaseRenewalThresholdMs = 30_000;
export type AutomationSchedulerCheckpoint = {
completedTenancyCursor: string | null,
activeTenancyId: string | null,
completedRuleCursor: string | null,
activeRuleId: string | null,
nextSubjectCursor: string | null,
};
export type AutomationSchedulerLease = {
ownerId: string,
checkpoint: AutomationSchedulerCheckpoint,
renewIfNeeded: () => Promise<void>,
saveCheckpoint: (checkpoint: AutomationSchedulerCheckpoint) => Promise<void>,
release: () => Promise<void>,
};
type AutomationSchedulerStatePrisma = Pick<PrismaClientTransaction, "automationSchedulerState">;
export function createPrismaAutomationSchedulerStateStore(options: {
prisma: AutomationSchedulerStatePrisma,
now?: () => Date,
ownerId?: () => string,
leaseDurationMs?: number,
renewalThresholdMs?: number,
}) {
const now = options.now ?? (() => new Date());
const ownerId = options.ownerId ?? randomUUID;
const leaseDurationMs = options.leaseDurationMs ?? automationSchedulerLeaseDurationMs;
const renewalThresholdMs = options.renewalThresholdMs ?? automationSchedulerLeaseRenewalThresholdMs;
validateLeaseDurations(leaseDurationMs, renewalThresholdMs);
return {
async acquire(): Promise<AutomationSchedulerLease | null> {
const acquiredAt = now();
const owner = ownerId();
const leaseExpiresAt = new Date(acquiredAt.getTime() + leaseDurationMs);
const acquired = await options.prisma.automationSchedulerState.updateMany({
where: {
key: automationSchedulerStateKey,
OR: [
{ leaseOwner: null },
{ leaseExpiresAt: { lte: acquiredAt } },
],
},
data: {
leaseOwner: owner,
leaseExpiresAt,
},
});
if (acquired.count === 0) {
const existing = await options.prisma.automationSchedulerState.findUnique({
where: { key: automationSchedulerStateKey },
select: { key: true },
});
if (existing === null) {
throw new Error(`Automation scheduler state "${automationSchedulerStateKey}" is missing. Apply the scheduler-state migration before running cron.`);
}
return null;
}
assertSingleMutation(acquired.count, "acquire automation scheduler lease");
const state = await options.prisma.automationSchedulerState.findUnique({
where: { key: automationSchedulerStateKey },
select: {
completedTenancyCursor: true,
activeTenancyId: true,
completedRuleCursor: true,
activeRuleId: true,
nextSubjectCursor: true,
leaseOwner: true,
leaseExpiresAt: true,
},
});
if (state === null || state.leaseOwner !== owner || state.leaseExpiresAt === null) {
throw new Error("Automation scheduler lease changed while it was being acquired.");
}
let currentLeaseExpiresAt = state.leaseExpiresAt;
let checkpoint = readCheckpoint(state);
let released = false;
const assertActive = () => {
if (released) {
throw new Error("Automation scheduler lease has already been released.");
}
};
const mutateOwnedUnexpiredLease = async (
action: string,
data: Parameters<typeof options.prisma.automationSchedulerState.updateMany>[0]["data"],
) => {
assertActive();
const mutationNow = now();
const result = await options.prisma.automationSchedulerState.updateMany({
where: {
key: automationSchedulerStateKey,
leaseOwner: owner,
leaseExpiresAt: { gt: mutationNow },
},
data,
});
assertSingleMutation(result.count, action);
};
return {
ownerId: owner,
get checkpoint() {
return checkpoint;
},
async renewIfNeeded() {
assertActive();
const renewalNow = now();
if (currentLeaseExpiresAt.getTime() - renewalNow.getTime() >= renewalThresholdMs) {
return;
}
const renewedUntil = new Date(renewalNow.getTime() + leaseDurationMs);
await mutateOwnedUnexpiredLease("renew automation scheduler lease", {
leaseExpiresAt: renewedUntil,
});
currentLeaseExpiresAt = renewedUntil;
},
async saveCheckpoint(nextCheckpoint) {
await mutateOwnedUnexpiredLease("save automation scheduler checkpoint", nextCheckpoint);
checkpoint = nextCheckpoint;
},
async release() {
assertActive();
const result = await options.prisma.automationSchedulerState.updateMany({
where: {
key: automationSchedulerStateKey,
leaseOwner: owner,
},
data: {
leaseOwner: null,
leaseExpiresAt: null,
},
});
assertSingleMutation(result.count, "release automation scheduler lease");
released = true;
},
};
},
};
}
function readCheckpoint(state: AutomationSchedulerCheckpoint): AutomationSchedulerCheckpoint {
return {
completedTenancyCursor: state.completedTenancyCursor,
activeTenancyId: state.activeTenancyId,
completedRuleCursor: state.completedRuleCursor,
activeRuleId: state.activeRuleId,
nextSubjectCursor: state.nextSubjectCursor,
};
}
function validateLeaseDurations(leaseDurationMs: number, renewalThresholdMs: number) {
if (!Number.isFinite(leaseDurationMs) || leaseDurationMs <= 0) {
throw new Error("Automation scheduler lease duration must be positive.");
}
if (!Number.isFinite(renewalThresholdMs) || renewalThresholdMs <= 0 || renewalThresholdMs >= leaseDurationMs) {
throw new Error("Automation scheduler renewal threshold must be positive and shorter than the lease duration.");
}
}
function assertSingleMutation(count: number, action: string) {
if (count !== 1) {
throw new Error(`Unable to ${action}; the scheduler lease was lost or expired.`);
}
}
+282 -296
View File
@@ -1,124 +1,120 @@
import { describe, expect, it, vi } from "vitest";
import type { AutomationSchedulerCheckpoint, AutomationSchedulerLease } from "./scheduler-state";
const captureErrorMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/automations/actions/send-email", () => ({
createSendEmailActionAdapter: () => ({}),
}));
vi.mock("@/lib/automations/execution-state-store", () => ({
createPrismaAutomationRuleExecutionStateStore: () => ({}),
}));
vi.mock("@/lib/automations/sources/payments-item-quota", () => ({
createPaymentsItemQuotaSourceAdapter: () => ({}),
paymentsItemQuotaCustomerDataReaders: {},
prismaPaymentsItemQuotaProjectUserReader: {},
}));
vi.mock("@/lib/emails", () => ({
sendEmailToMany: async () => {},
}));
vi.mock("@/lib/tenancies", () => ({
getTenancy: async () => null,
}));
vi.mock("@/lib/emails", () => ({ sendEmailToMany: async () => {} }));
vi.mock("@/lib/tenancies", () => ({ getTenancy: async () => null }));
vi.mock("@/prisma-client", () => ({
getPrismaClientForTenancy: async () => ({}),
globalPrismaClient: {
tenancy: {
findMany: async () => [],
},
outgoingRequest: {
createMany: async () => ({ count: 0 }),
},
tenancy: { findMany: async () => [] },
automationSchedulerState: {},
},
}));
vi.mock("@hexclave/shared/dist/utils/errors", async (importOriginal) => {
const actual = await importOriginal<typeof import("@hexclave/shared/dist/utils/errors")>();
return {
...actual,
captureError: captureErrorMock,
};
return { ...actual, captureError: captureErrorMock };
});
import {
discoverEnabledScheduledAutomationRules,
enqueueScheduledAutomationRuns,
getScheduledAutomationDeduplicationKey,
runScheduledAutomationRulePage,
scheduledAutomationRunRoutePath,
normalizeScheduledAutomationDiscoveryLimit,
normalizeScheduledAutomationMaxPages,
normalizeScheduledAutomationRunPageLimit,
normalizeScheduledAutomationWorkBudgetMs,
runScheduledAutomations,
} from "./scheduler";
import { AutomationRunResult } from "./run-route";
import type { AutomationRunResult } from "./run-route";
const enabledRuleId = "low-api-credits";
const ruleId = "low-api-credits";
function createAutomationRule(options: {
enabled?: boolean,
sourceType?: string,
} = {}) {
function emptyCheckpoint(): AutomationSchedulerCheckpoint {
return {
completedTenancyCursor: null,
activeTenancyId: null,
completedRuleCursor: null,
activeRuleId: null,
nextSubjectCursor: null,
};
}
function createStateHarness(initial: AutomationSchedulerCheckpoint = emptyCheckpoint()) {
let checkpoint = initial;
const saveCheckpoint = vi.fn(async (next: AutomationSchedulerCheckpoint) => {
checkpoint = next;
});
const release = vi.fn(async () => {});
const renewIfNeeded = vi.fn(async () => {});
const acquire = vi.fn(async (): Promise<AutomationSchedulerLease> => ({
ownerId: "00000000-0000-4000-8000-000000000001",
get checkpoint() {
return checkpoint;
},
saveCheckpoint,
release,
renewIfNeeded,
}));
return {
stateStore: { acquire },
getCheckpoint: () => checkpoint,
acquire,
saveCheckpoint,
release,
renewIfNeeded,
};
}
function createPrisma(tenancyIds: string[]) {
return {
tenancy: {
findMany: vi.fn(async (options: any) => tenancyIds
.filter((id) => options.where.id?.gt === undefined || id > options.where.id.gt)
.slice(0, options.take)
.map((id) => ({ id }))),
},
};
}
function createRule(options: { enabled?: boolean, sourceType?: string } = {}) {
return {
enabled: options.enabled ?? true,
source: {
type: options.sourceType ?? "payments-item-quota",
itemId: "api_credits",
itemId: "api-credits",
customerType: "user",
thresholds: {
nearRemainingQuantity: 10,
},
thresholds: { nearRemainingQuantity: 10 },
},
action: {
type: "send-email",
templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1",
notificationCategoryName: "Marketing",
},
cooldown: {
days: 7,
},
cooldown: { days: 7 },
};
}
function createTenancy(options: {
enabled?: boolean,
sourceType?: string,
rules?: Record<string, ReturnType<typeof createAutomationRule>>,
} = {}) {
function createTenancy(id: string, rules: Record<string, ReturnType<typeof createRule>> = { [ruleId]: createRule() }) {
return {
id: "tenancy-1",
branchId: "main",
organization: null,
project: {
id: "project-1",
display_name: "Acme App",
description: "",
logo_url: null,
logo_full_url: null,
logo_dark_mode_url: null,
logo_full_dark_mode_url: null,
created_at_millis: 0,
is_production_mode: false,
is_development_environment: false,
owner_team_id: null,
onboarding_status: "completed",
onboarding_state: undefined,
pushed_config_error: null,
config_warnings: [],
},
config: {
automations: {
rules: options.rules ?? {
[enabledRuleId]: createAutomationRule(options),
},
},
},
id,
project: { display_name: "Acme App" },
config: { automations: { rules } },
};
}
function createRunResult(nextCursor: string | null): AutomationRunResult {
function createRunResult(nextCursor: string | null, options: Partial<AutomationRunResult> = {}): AutomationRunResult {
return {
ruleId: enabledRuleId,
ruleId,
mode: "run",
evaluatedCount: 100,
eligibleCount: 1,
@@ -126,258 +122,248 @@ function createRunResult(nextCursor: string | null): AutomationRunResult {
sentCount: 1,
nextCursor,
decisions: [],
...options,
};
}
describe("scheduled automation discovery", () => {
it("finds enabled V1 rules from bounded tenancy pages", async () => {
const prisma = {
tenancy: {
findMany: vi.fn(async () => [{ id: "tenancy-1" }, { id: "tenancy-2" }]),
},
};
const getTenancyById = vi.fn(async (tenancyId: string) => tenancyId === "tenancy-1"
? createTenancy()
: createTenancy({ enabled: false }));
await expect(discoverEnabledScheduledAutomationRules({
prisma,
getTenancyById,
limit: 2,
cursor: "previous-tenancy",
})).resolves.toMatchInlineSnapshot(`
{
"nextCursor": "tenancy-2",
"scannedTenancyCount": 2,
"targets": [
{
"ruleId": "low-api-credits",
"tenancyId": "tenancy-1",
},
],
}
`);
expect(prisma.tenancy.findMany).toHaveBeenCalledWith({
where: {
id: {
gt: "previous-tenancy",
},
},
orderBy: {
id: "asc",
},
take: 2,
select: {
id: true,
},
});
});
it("reports malformed enabled rules and continues discovering later valid rules", async () => {
captureErrorMock.mockClear();
const prisma = {
tenancy: {
findMany: vi.fn(async () => [{ id: "tenancy-1" }]),
},
};
await expect(discoverEnabledScheduledAutomationRules({
prisma,
getTenancyById: async () => createTenancy({
rules: {
invalidRule: createAutomationRule({ sourceType: "client-push-quota" }),
[enabledRuleId]: createAutomationRule(),
},
}),
})).resolves.toMatchInlineSnapshot(`
{
"nextCursor": null,
"scannedTenancyCount": 1,
"targets": [
{
"ruleId": "low-api-credits",
"tenancyId": "tenancy-1",
},
],
}
`);
expect(captureErrorMock).toHaveBeenCalledWith("automation-scheduler-invalid-rule", expect.any(Error));
describe("scheduled automation bounds", () => {
it("strictly normalizes internal limits", () => {
expect(normalizeScheduledAutomationDiscoveryLimit(900)).toBe(500);
expect(normalizeScheduledAutomationRunPageLimit(900)).toBe(100);
expect(normalizeScheduledAutomationMaxPages(900)).toBe(10);
expect(normalizeScheduledAutomationWorkBudgetMs(90_000)).toBe(45_000);
});
});
describe("scheduled automation queueing", () => {
it("enqueues deduped QStash work through OutgoingRequest", async () => {
const prisma = {
outgoingRequest: {
createMany: vi.fn(async () => ({ count: 1 })),
},
};
await expect(enqueueScheduledAutomationRuns({
prisma,
targets: [{
tenancyId: "tenancy-1",
ruleId: enabledRuleId,
}],
scheduledAt: new Date("2026-07-01T12:00:00.000Z"),
limit: 75,
})).resolves.toEqual({ enqueuedCount: 1 });
expect(prisma.outgoingRequest.createMany).toHaveBeenCalledWith({
data: [{
deduplicationKey: "automation-rule-run:tenancy-1:low-api-credits:start",
qstashOptions: {
body: {
cursor: null,
limit: 75,
ruleId: "low-api-credits",
scheduledAtMillis: 1782907200000,
tenancyId: "tenancy-1",
},
flowControl: {
key: "automation-rule-run:tenancy-1",
parallelism: 1,
},
url: scheduledAutomationRunRoutePath,
},
}],
skipDuplicates: true,
describe("native cron automation traversal", () => {
it("returns without work when another invocation holds the lease", async () => {
await expect(runScheduledAutomations({
stateStore: { acquire: async () => null },
})).resolves.toMatchObject({
status: "lease-held",
pagesProcessed: 0,
sentCount: 0,
});
});
it("reports zero enqueues when a duplicate pending OutgoingRequest already exists", async () => {
const prisma = {
outgoingRequest: {
createMany: vi.fn(async () => ({ count: 0 })),
},
};
it("persists the next subject cursor only after a page succeeds", async () => {
const state = createStateHarness();
const runRule = vi.fn(async () => createRunResult("00000000-0000-4000-8000-000000000100"));
await expect(enqueueScheduledAutomationRuns({
prisma,
targets: [{
tenancyId: "tenancy-1",
ruleId: enabledRuleId,
}],
scheduledAt: new Date("2026-07-01T12:00:00.000Z"),
})).resolves.toEqual({ enqueuedCount: 0 });
expect(prisma.outgoingRequest.createMany).toHaveBeenCalledWith(expect.objectContaining({
skipDuplicates: true,
}));
});
it("uses cursor-specific dedupe keys for continuation pages", () => {
expect(getScheduledAutomationDeduplicationKey({
tenancyId: "tenancy-1",
ruleId: enabledRuleId,
cursor: "user-100",
})).toBe("automation-rule-run:tenancy-1:low-api-credits:user-100");
});
});
describe("scheduled automation worker orchestration", () => {
it("runs a page and enqueues continuation when the evaluator returns a next cursor", async () => {
const runRule = vi.fn(async () => createRunResult("user-100"));
const enqueueContinuation = vi.fn(async () => ({ enqueuedCount: 1 }));
await expect(runScheduledAutomationRulePage({
tenancyId: "tenancy-1",
ruleId: enabledRuleId,
limit: 100,
scheduledAt: new Date("2026-07-01T12:00:00.000Z"),
now: new Date("2026-07-01T12:01:00.000Z"),
getTenancyById: async () => createTenancy(),
await expect(runScheduledAutomations({
prisma: createPrisma(["00000000-0000-4000-8000-000000000010"]),
stateStore: state.stateStore,
getTenancyById: async (id) => createTenancy(id),
runRule,
enqueueContinuation,
})).resolves.toMatchInlineSnapshot(`
{
"enqueuedContinuation": true,
"result": {
"decisions": [],
"eligibleCount": 1,
"evaluatedCount": 100,
"mode": "run",
"nextCursor": "user-100",
"ruleId": "low-api-credits",
"sentCount": 1,
"suppressedCount": 0,
},
"status": "ran",
}
`);
maxPages: 1,
})).resolves.toMatchObject({
status: "ran",
pagesProcessed: 1,
evaluatedCount: 100,
sentCount: 1,
});
expect(runRule).toHaveBeenCalledWith(expect.objectContaining({
cursor: null,
limit: 100,
ruleId: enabledRuleId,
}));
expect(enqueueContinuation).toHaveBeenCalledWith({
tenancyId: "tenancy-1",
ruleId: enabledRuleId,
cursor: "user-100",
limit: 100,
scheduledAt: new Date("2026-07-01T12:00:00.000Z"),
expect(state.getCheckpoint()).toMatchObject({
activeRuleId: ruleId,
nextSubjectCursor: "00000000-0000-4000-8000-000000000100",
});
expect(state.renewIfNeeded).toHaveBeenCalledOnce();
expect(state.release).toHaveBeenCalledOnce();
});
it("reports duplicate continuation work without failing the completed page", async () => {
const runRule = vi.fn(async () => createRunResult("user-100"));
const enqueueContinuation = vi.fn(async () => ({ enqueuedCount: 0 }));
it("uses a monotonic clock for the work budget", async () => {
const state = createStateHarness();
let elapsedMillis = 0;
let wallClockMillis = Date.UTC(2026, 0, 1);
const runRule = vi.fn(async () => {
elapsedMillis = 10;
return createRunResult("00000000-0000-4000-8000-000000000100");
});
await expect(runScheduledAutomationRulePage({
tenancyId: "tenancy-1",
ruleId: enabledRuleId,
limit: 100,
scheduledAt: new Date("2026-07-01T12:00:00.000Z"),
now: new Date("2026-07-01T12:01:00.000Z"),
getTenancyById: async () => createTenancy(),
await expect(runScheduledAutomations({
prisma: createPrisma(["00000000-0000-4000-8000-000000000010"]),
stateStore: state.stateStore,
getTenancyById: async (id) => createTenancy(id),
runRule,
enqueueContinuation,
now: () => new Date(wallClockMillis -= 60_000),
elapsedNow: () => elapsedMillis,
workBudgetMs: 10,
})).resolves.toMatchObject({
status: "ran",
enqueuedContinuation: false,
result: {
nextCursor: "user-100",
},
pagesProcessed: 1,
});
});
it("propagates continuation enqueue failures so the queue can retry the page", async () => {
const runRule = vi.fn(async () => createRunResult("user-100"));
const enqueueContinuation = vi.fn(async () => {
throw new Error("queue unavailable");
});
await expect(runScheduledAutomationRulePage({
tenancyId: "tenancy-1",
ruleId: enabledRuleId,
limit: 100,
scheduledAt: new Date("2026-07-01T12:00:00.000Z"),
now: new Date("2026-07-01T12:01:00.000Z"),
getTenancyById: async () => createTenancy(),
runRule,
enqueueContinuation,
})).rejects.toThrow("queue unavailable");
expect(runRule).toHaveBeenCalledOnce();
expect(state.getCheckpoint()).toMatchObject({
activeRuleId: ruleId,
nextSubjectCursor: "00000000-0000-4000-8000-000000000100",
});
});
it("skips stale queued work when the rule was disabled after enqueue", async () => {
it("preserves the current page checkpoint and fails on transient execution errors", async () => {
const initial = {
completedTenancyCursor: "00000000-0000-4000-8000-000000000001",
activeTenancyId: "00000000-0000-4000-8000-000000000010",
completedRuleCursor: null,
activeRuleId: ruleId,
nextSubjectCursor: "00000000-0000-4000-8000-000000000100",
};
const state = createStateHarness(initial);
const runRule = vi.fn(async () => {
throw new Error("email enqueue failed");
});
await expect(runScheduledAutomations({
prisma: createPrisma([]),
stateStore: state.stateStore,
getTenancyById: async (id) => createTenancy(id),
runRule,
})).rejects.toThrow("email enqueue failed");
expect(runRule).toHaveBeenCalledWith(expect.objectContaining({
cursor: initial.nextSubjectCursor,
}));
expect(state.getCheckpoint()).toEqual(initial);
expect(state.release).toHaveBeenCalledOnce();
});
it("propagates discovery database failures without changing the checkpoint", async () => {
const state = createStateHarness();
const prisma = {
tenancy: {
findMany: vi.fn(async () => {
throw new Error("database unavailable");
}),
},
};
await expect(runScheduledAutomations({
prisma,
stateStore: state.stateStore,
})).rejects.toThrow("database unavailable");
expect(state.getCheckpoint()).toEqual(emptyCheckpoint());
expect(state.release).toHaveBeenCalledOnce();
});
it("advances a deleted tenancy without executing a rule", async () => {
const state = createStateHarness();
const tenancyId = "00000000-0000-4000-8000-000000000010";
const runRule = vi.fn(async () => createRunResult(null));
await expect(runScheduledAutomationRulePage({
tenancyId: "tenancy-1",
ruleId: enabledRuleId,
scheduledAt: new Date("2026-07-01T12:00:00.000Z"),
now: new Date("2026-07-01T12:01:00.000Z"),
getTenancyById: async () => createTenancy({ enabled: false }),
await expect(runScheduledAutomations({
prisma: createPrisma([tenancyId]),
stateStore: state.stateStore,
getTenancyById: async () => null,
runRule,
})).resolves.toMatchInlineSnapshot(`
{
"reason": "rule-disabled",
"status": "skipped",
}
`);
maxTenancies: 1,
})).resolves.toMatchObject({
tenanciesScanned: 1,
cycleCompleted: false,
});
expect(state.getCheckpoint()).toEqual({
completedTenancyCursor: tenancyId,
activeTenancyId: null,
completedRuleCursor: null,
activeRuleId: null,
nextSubjectCursor: null,
});
expect(runRule).not.toHaveBeenCalled();
});
it("completes rule and tenancy boundaries without resetting the full cycle", async () => {
const state = createStateHarness();
const tenancyId = "00000000-0000-4000-8000-000000000010";
const first = await runScheduledAutomations({
prisma: createPrisma([tenancyId]),
stateStore: state.stateStore,
getTenancyById: async (id) => createTenancy(id),
runRule: async () => createRunResult(null),
maxPages: 1,
});
expect(first.cycleCompleted).toBe(false);
expect(state.getCheckpoint()).toEqual({
completedTenancyCursor: null,
activeTenancyId: tenancyId,
completedRuleCursor: ruleId,
activeRuleId: null,
nextSubjectCursor: null,
});
const second = await runScheduledAutomations({
prisma: createPrisma([tenancyId]),
stateStore: state.stateStore,
getTenancyById: async (id) => createTenancy(id),
runRule: async () => createRunResult(null),
});
expect(second.cycleCompleted).toBe(true);
expect(state.getCheckpoint()).toEqual(emptyCheckpoint());
});
it("continues after a full discovery page and resets only at the actual end", async () => {
const tenancyIds = Array.from({ length: 501 }, (_, index) => (
`00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`
));
const state = createStateHarness();
const prisma = createPrisma(tenancyIds);
const first = await runScheduledAutomations({
prisma,
stateStore: state.stateStore,
getTenancyById: async (id) => createTenancy(id, {}),
runRule: async () => createRunResult(null),
maxTenancies: 500,
});
expect(first).toMatchObject({ tenanciesScanned: 500, cycleCompleted: false });
expect(state.getCheckpoint().completedTenancyCursor).toBe(tenancyIds[499]);
const second = await runScheduledAutomations({
prisma,
stateStore: state.stateStore,
getTenancyById: async (id) => createTenancy(id, {}),
runRule: async () => createRunResult(null),
maxTenancies: 500,
});
expect(second).toMatchObject({ tenanciesScanned: 1, cycleCompleted: true });
expect(state.getCheckpoint()).toEqual(emptyCheckpoint());
});
it("reports malformed rules, advances them, and runs the next valid rule", async () => {
captureErrorMock.mockClear();
const state = createStateHarness();
const validRuleId = "valid-rule";
const runRule = vi.fn(async () => createRunResult(null, { ruleId: validRuleId }));
await runScheduledAutomations({
prisma: createPrisma(["00000000-0000-4000-8000-000000000010"]),
stateStore: state.stateStore,
getTenancyById: async (id) => createTenancy(id, {
"invalid-rule": createRule({ sourceType: "client-push-quota" }),
[validRuleId]: createRule(),
}),
runRule,
maxPages: 1,
});
expect(captureErrorMock).toHaveBeenCalledWith("automation-scheduler-invalid-rule", expect.any(Error));
expect(runRule).toHaveBeenCalledWith(expect.objectContaining({ ruleId: validRuleId }));
});
it("skips disabled rules without executing them", async () => {
const state = createStateHarness();
const runRule = vi.fn(async () => createRunResult(null));
await runScheduledAutomations({
prisma: createPrisma(["00000000-0000-4000-8000-000000000010"]),
stateStore: state.stateStore,
getTenancyById: async (id) => createTenancy(id, { [ruleId]: createRule({ enabled: false }) }),
runRule,
});
expect(runRule).not.toHaveBeenCalled();
});
+322 -279
View File
@@ -1,9 +1,10 @@
import { createSendEmailActionAdapter } from "@/lib/automations/actions/send-email";
import { createPrismaAutomationRuleExecutionStateStore } from "@/lib/automations/execution-state-store";
import {
AutomationRunResult,
runAutomationRuleForRoute,
} from "@/lib/automations/run-route";
createPrismaAutomationSchedulerStateStore,
type AutomationSchedulerCheckpoint,
type AutomationSchedulerLease,
} from "@/lib/automations/scheduler-state";
import {
createPaymentsItemQuotaSourceAdapter,
paymentsItemQuotaCustomerDataReaders,
@@ -13,82 +14,31 @@ import { sendEmailToMany } from "@/lib/emails";
import { getTenancy, type Tenancy } from "@/lib/tenancies";
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
import { captureError, HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";
import { assertSupportedAutomationRule, AutomationRuleTenancy, getAutomationRule, listAutomationRules } from "./rules";
import { stringCompare } from "@hexclave/shared/dist/utils/strings";
import { runAutomationRuleForRoute, type AutomationRunResult } from "./run-route";
import { assertSupportedAutomationRule, type AutomationRuleTenancy, listAutomationRules } from "./rules";
export const scheduledAutomationDiscoveryLimit = 500;
export const scheduledAutomationRunPageLimit = 100;
export const scheduledAutomationRunRoutePath = "/api/latest/internal/automations/scheduled-run";
type AutomationScheduleTarget = {
tenancyId: string,
ruleId: string,
};
export type AutomationScheduleDiscoveryResult = {
scannedTenancyCount: number,
targets: AutomationScheduleTarget[],
nextCursor: string | null,
};
export type AutomationScheduledRunPayload = {
tenancyId: string,
ruleId: string,
cursor: string | null,
limit: number,
scheduledAtMillis: number,
};
export type AutomationScheduledRunResult =
| {
status: "skipped",
reason: "tenancy-not-found" | "rule-not-found" | "rule-disabled",
}
| {
status: "ran",
result: AutomationRunResult,
enqueuedContinuation: boolean,
};
export const scheduledAutomationMaxPages = 10;
export const scheduledAutomationDefaultPages = 5;
export const scheduledAutomationWorkBudgetMs = 45_000;
type AutomationDiscoveryPrisma = {
tenancy: {
findMany: (options: {
where: {
id?: {
gt: string,
},
},
orderBy: {
id: "asc",
id?: { gt: string },
},
orderBy: { id: "asc" },
take: number,
select: {
id: true,
},
select: { id: true },
}) => Promise<Array<{ id: string }>>,
},
};
type AutomationQueuePrisma = {
outgoingRequest: {
createMany: (options: {
data: Array<{
qstashOptions: {
url: string,
body: AutomationScheduledRunPayload,
flowControl: {
key: string,
parallelism: number,
},
},
deduplicationKey: string,
}>,
skipDuplicates: true,
}) => Promise<{ count: number }>,
},
};
type ScheduledAutomationRunner = (options: {
tenancy: AutomationRuleTenancy,
type ScheduledAutomationRunner<TTenancy extends AutomationRuleTenancy> = (options: {
tenancy: TTenancy,
ruleId: string,
cursor: string | null,
limit: number,
@@ -96,6 +46,32 @@ type ScheduledAutomationRunner = (options: {
now: Date,
}) => Promise<AutomationRunResult>;
type AutomationSchedulerStateStore = {
acquire: () => Promise<AutomationSchedulerLease | null>,
};
type ScheduledAutomationOptions = {
prisma?: AutomationDiscoveryPrisma,
stateStore?: AutomationSchedulerStateStore,
now?: () => Date,
elapsedNow?: () => number,
pageLimit?: number,
maxPages?: number,
maxTenancies?: number,
workBudgetMs?: number,
};
export type ScheduledAutomationCronResult = {
status: "ran" | "lease-held",
tenanciesScanned: number,
rulesProcessed: number,
pagesProcessed: number,
evaluatedCount: number,
sentCount: number,
suppressedCount: number,
cycleCompleted: boolean,
};
export function normalizeScheduledAutomationDiscoveryLimit(limit: number | undefined) {
if (limit === undefined) return scheduledAutomationDiscoveryLimit;
return Math.max(1, Math.min(Math.floor(limit), scheduledAutomationDiscoveryLimit));
@@ -106,227 +82,260 @@ export function normalizeScheduledAutomationRunPageLimit(limit: number | undefin
return Math.max(1, Math.min(Math.floor(limit), scheduledAutomationRunPageLimit));
}
export async function discoverEnabledScheduledAutomationRules(options: {
prisma?: AutomationDiscoveryPrisma,
getTenancyById?: (tenancyId: string) => Promise<AutomationRuleTenancy | null>,
limit?: number,
cursor?: string | null,
} = {}): Promise<AutomationScheduleDiscoveryResult> {
const prisma = options.prisma ?? globalPrismaClient;
const limit = normalizeScheduledAutomationDiscoveryLimit(options.limit);
const tenancyRows = await prisma.tenancy.findMany({
where: {
...(options.cursor == null ? {} : {
id: {
gt: options.cursor,
},
}),
},
orderBy: {
id: "asc",
},
take: limit,
select: {
id: true,
},
});
const getTenancyById = options.getTenancyById ?? getTenancy;
const targets: AutomationScheduleTarget[] = [];
export function normalizeScheduledAutomationMaxPages(limit: number | undefined) {
if (limit === undefined) return scheduledAutomationDefaultPages;
return Math.max(1, Math.min(Math.floor(limit), scheduledAutomationMaxPages));
}
for (const row of tenancyRows) {
const tenancy = await getTenancyById(row.id);
export function normalizeScheduledAutomationWorkBudgetMs(value: number | undefined) {
if (value === undefined) return scheduledAutomationWorkBudgetMs;
return Math.max(1, Math.min(Math.floor(value), scheduledAutomationWorkBudgetMs));
}
export function runScheduledAutomations(
options?: ScheduledAutomationOptions & {
getTenancyById?: undefined,
runRule?: undefined,
},
): Promise<ScheduledAutomationCronResult>;
export function runScheduledAutomations<TTenancy extends AutomationRuleTenancy>(
options: ScheduledAutomationOptions & {
getTenancyById: (tenancyId: string) => Promise<TTenancy | null>,
runRule: ScheduledAutomationRunner<TTenancy>,
},
): Promise<ScheduledAutomationCronResult>;
export async function runScheduledAutomations(options: ScheduledAutomationOptions & {
getTenancyById?: (tenancyId: string) => Promise<AutomationRuleTenancy | null>,
runRule?: ScheduledAutomationRunner<AutomationRuleTenancy>,
} = {}): Promise<ScheduledAutomationCronResult> {
if (options.getTenancyById === undefined && options.runRule === undefined) {
return await runScheduledAutomationsWithDependencies(options, {
getTenancyById: getTenancy,
runRule: runProductionScheduledAutomationRulePage,
});
}
if (options.getTenancyById === undefined || options.runRule === undefined) {
throw new Error("Automation scheduler test dependencies must provide both getTenancyById and runRule.");
}
return await runScheduledAutomationsWithDependencies(options, {
getTenancyById: options.getTenancyById,
runRule: options.runRule,
});
}
async function runScheduledAutomationsWithDependencies<TTenancy extends AutomationRuleTenancy>(
options: ScheduledAutomationOptions,
dependencies: {
getTenancyById: (tenancyId: string) => Promise<TTenancy | null>,
runRule: ScheduledAutomationRunner<TTenancy>,
},
): Promise<ScheduledAutomationCronResult> {
const prisma = options.prisma ?? globalPrismaClient;
const stateStore = options.stateStore ?? createPrismaAutomationSchedulerStateStore({
prisma: globalPrismaClient,
});
const lease = await stateStore.acquire();
if (lease === null) {
return emptyCronResult("lease-held");
}
try {
const result = await runWithLease({
prisma,
lease,
getTenancyById: dependencies.getTenancyById,
runRule: dependencies.runRule,
now: options.now ?? (() => new Date()),
elapsedNow: options.elapsedNow ?? (() => performance.now()),
pageLimit: normalizeScheduledAutomationRunPageLimit(options.pageLimit),
maxPages: normalizeScheduledAutomationMaxPages(options.maxPages),
maxTenancies: normalizeScheduledAutomationDiscoveryLimit(options.maxTenancies),
workBudgetMs: normalizeScheduledAutomationWorkBudgetMs(options.workBudgetMs),
});
await lease.release();
return result;
} catch (error) {
try {
await lease.release();
} catch (releaseError) {
captureError("automation-scheduler-release-after-failure", new HexclaveAssertionError("Failed to release the automation scheduler lease after an execution failure.", {
cause: releaseError,
}));
}
throw error;
}
}
async function runWithLease<TTenancy extends AutomationRuleTenancy>(options: {
prisma: AutomationDiscoveryPrisma,
lease: AutomationSchedulerLease,
getTenancyById: (tenancyId: string) => Promise<TTenancy | null>,
runRule: ScheduledAutomationRunner<TTenancy>,
now: () => Date,
elapsedNow: () => number,
pageLimit: number,
maxPages: number,
maxTenancies: number,
workBudgetMs: number,
}): Promise<ScheduledAutomationCronResult> {
const startedAt = options.now();
const startedElapsedAt = options.elapsedNow();
const scheduledAt = startedAt;
let checkpoint = options.lease.checkpoint;
let tenanciesScanned = 0;
let rulesProcessed = 0;
let pagesProcessed = 0;
let evaluatedCount = 0;
let sentCount = 0;
let suppressedCount = 0;
let cycleCompleted = false;
let discoveredTenancyIds: string[] = [];
const saveCheckpoint = async (next: AutomationSchedulerCheckpoint) => {
await options.lease.saveCheckpoint(next);
checkpoint = next;
};
while (
pagesProcessed < options.maxPages
&& tenanciesScanned < options.maxTenancies
&& options.elapsedNow() - startedElapsedAt < options.workBudgetMs
) {
if (checkpoint.activeTenancyId === null) {
if (discoveredTenancyIds.length === 0) {
const rows = await options.prisma.tenancy.findMany({
where: checkpoint.completedTenancyCursor === null ? {} : {
id: { gt: checkpoint.completedTenancyCursor },
},
orderBy: { id: "asc" },
take: Math.min(
scheduledAutomationDiscoveryLimit,
options.maxTenancies - tenanciesScanned,
),
select: { id: true },
});
if (rows.length === 0) {
await saveCheckpoint(emptyCheckpoint());
cycleCompleted = true;
break;
}
discoveredTenancyIds = rows.map((row) => row.id);
}
const nextTenancyId = discoveredTenancyIds.shift();
if (nextTenancyId === undefined) {
throw new Error("Automation scheduler discovery returned no next tenancy unexpectedly.");
}
await saveCheckpoint({
...checkpoint,
activeTenancyId: nextTenancyId,
completedRuleCursor: null,
activeRuleId: null,
nextSubjectCursor: null,
});
tenanciesScanned++;
}
const activeTenancyId = checkpoint.activeTenancyId;
if (activeTenancyId === null) {
throw new Error("Automation scheduler checkpoint lost its active tenancy unexpectedly.");
}
const tenancy = await options.getTenancyById(activeTenancyId);
if (tenancy === null) {
await saveCheckpoint(completeActiveTenancy(checkpoint));
continue;
}
for (const { ruleId, rule } of listAutomationRules(tenancy)) {
if (!rule.enabled) {
const sortedRules = listAutomationRules(tenancy)
.sort((left, right) => stringCompare(left.ruleId, right.ruleId));
if (checkpoint.activeRuleId === null) {
const nextRule = sortedRules.find(({ ruleId }) => (
checkpoint.completedRuleCursor === null || stringCompare(ruleId, checkpoint.completedRuleCursor) > 0
));
if (nextRule === undefined) {
await saveCheckpoint(completeActiveTenancy(checkpoint));
continue;
}
try {
assertSupportedAutomationRule(ruleId, rule);
} catch (error) {
captureError("automation-scheduler-invalid-rule", new HexclaveAssertionError(`Skipping invalid scheduled automation rule "${ruleId}" for tenancy "${tenancy.id}".`, {
cause: error,
tenancyId: tenancy.id,
ruleId,
}));
continue;
}
targets.push({
await saveCheckpoint({
...checkpoint,
activeRuleId: nextRule.ruleId,
nextSubjectCursor: null,
});
}
const activeRuleId = checkpoint.activeRuleId;
if (activeRuleId === null) {
throw new Error("Automation scheduler checkpoint lost its active rule unexpectedly.");
}
const ruleEntry = sortedRules.find(({ ruleId }) => ruleId === activeRuleId);
if (ruleEntry === undefined) {
await saveCheckpoint(completeActiveRule(checkpoint, activeRuleId));
rulesProcessed++;
continue;
}
if (!ruleEntry.rule.enabled) {
await saveCheckpoint(completeActiveRule(checkpoint, activeRuleId));
rulesProcessed++;
continue;
}
try {
assertSupportedAutomationRule(activeRuleId, ruleEntry.rule);
} catch (error) {
captureError("automation-scheduler-invalid-rule", new HexclaveAssertionError(`Skipping invalid scheduled automation rule "${activeRuleId}" for tenancy "${tenancy.id}".`, {
cause: error,
tenancyId: tenancy.id,
ruleId,
ruleId: activeRuleId,
}));
await saveCheckpoint(completeActiveRule(checkpoint, activeRuleId));
rulesProcessed++;
continue;
}
if (
pagesProcessed >= options.maxPages
|| options.elapsedNow() - startedElapsedAt >= options.workBudgetMs
) {
break;
}
await options.lease.renewIfNeeded();
const result = await options.runRule({
tenancy,
ruleId: activeRuleId,
cursor: checkpoint.nextSubjectCursor,
limit: options.pageLimit,
scheduledAt,
now: options.now(),
});
pagesProcessed++;
evaluatedCount += result.evaluatedCount;
sentCount += result.sentCount;
suppressedCount += result.suppressedCount;
if (result.nextCursor === null) {
await saveCheckpoint(completeActiveRule(checkpoint, activeRuleId));
rulesProcessed++;
} else {
await saveCheckpoint({
...checkpoint,
nextSubjectCursor: result.nextCursor,
});
}
}
return {
scannedTenancyCount: tenancyRows.length,
targets,
nextCursor: tenancyRows.length === limit ? tenancyRows[tenancyRows.length - 1]?.id ?? null : null,
};
}
export async function enqueueScheduledAutomationRuns(options: {
prisma?: AutomationQueuePrisma,
targets: AutomationScheduleTarget[],
limit?: number,
cursor?: string | null,
scheduledAt: Date,
}): Promise<{ enqueuedCount: number }> {
if (options.targets.length === 0) {
return { enqueuedCount: 0 };
}
const prisma = options.prisma ?? globalPrismaClient;
const limit = normalizeScheduledAutomationRunPageLimit(options.limit);
const cursor = options.cursor ?? null;
const createResult = await prisma.outgoingRequest.createMany({
data: options.targets.map((target) => ({
qstashOptions: {
url: scheduledAutomationRunRoutePath,
body: {
tenancyId: target.tenancyId,
ruleId: target.ruleId,
cursor,
limit,
scheduledAtMillis: options.scheduledAt.getTime(),
},
flowControl: {
key: getScheduledAutomationFlowControlKey(target.tenancyId),
parallelism: 1,
},
},
deduplicationKey: getScheduledAutomationDeduplicationKey({
...target,
cursor,
}),
})),
skipDuplicates: true,
});
return {
enqueuedCount: createResult.count,
};
}
export async function enqueueScheduledAutomationContinuation(options: {
tenancyId: string,
ruleId: string,
cursor: string,
limit: number,
scheduledAt: Date,
prisma?: AutomationQueuePrisma,
}): Promise<{ enqueuedCount: number }> {
return await enqueueScheduledAutomationRuns({
prisma: options.prisma,
targets: [{
tenancyId: options.tenancyId,
ruleId: options.ruleId,
}],
cursor: options.cursor,
limit: options.limit,
scheduledAt: options.scheduledAt,
});
}
export async function runScheduledAutomationRulePage(options: {
tenancyId: string,
ruleId: string,
cursor?: string | null,
limit?: number,
scheduledAt: Date,
now: Date,
getTenancyById?: (tenancyId: string) => Promise<AutomationRuleTenancy | null>,
runRule?: ScheduledAutomationRunner,
enqueueContinuation?: typeof enqueueScheduledAutomationContinuation,
}): Promise<AutomationScheduledRunResult> {
const getTenancyById = options.getTenancyById ?? getTenancy;
const tenancy = await getTenancyById(options.tenancyId);
if (tenancy === null) {
return {
status: "skipped",
reason: "tenancy-not-found",
};
}
const rule = getAutomationRule(tenancy, options.ruleId);
if (rule === undefined) {
return {
status: "skipped",
reason: "rule-not-found",
};
}
if (!rule.enabled) {
return {
status: "skipped",
reason: "rule-disabled",
};
}
assertSupportedAutomationRule(options.ruleId, rule);
const limit = normalizeScheduledAutomationRunPageLimit(options.limit);
const result = options.runRule === undefined
? await runProductionScheduledAutomationRulePage({
tenancyId: options.tenancyId,
ruleId: options.ruleId,
cursor: options.cursor ?? null,
limit,
scheduledAt: options.scheduledAt,
now: options.now,
})
: await options.runRule({
tenancy,
ruleId: options.ruleId,
cursor: options.cursor ?? null,
limit,
scheduledAt: options.scheduledAt,
now: options.now,
});
let enqueuedContinuation = false;
if (result.nextCursor !== null) {
const enqueueContinuation = options.enqueueContinuation ?? enqueueScheduledAutomationContinuation;
const enqueueResult = await enqueueContinuation({
tenancyId: options.tenancyId,
ruleId: options.ruleId,
cursor: result.nextCursor,
limit,
scheduledAt: options.scheduledAt,
});
enqueuedContinuation = enqueueResult.enqueuedCount > 0;
}
return {
status: "ran",
result,
enqueuedContinuation,
tenanciesScanned,
rulesProcessed,
pagesProcessed,
evaluatedCount,
sentCount,
suppressedCount,
cycleCompleted,
};
}
async function runProductionScheduledAutomationRulePage(options: {
tenancyId: string,
ruleId: string,
cursor: string | null,
limit: number,
scheduledAt: Date,
now: Date,
}) {
const tenancy = await getTenancy(options.tenancyId);
if (tenancy === null) {
throw new Error(`Tenancy "${options.tenancyId}" disappeared between scheduled automation validation and execution.`);
}
return await runAutomationRuleForScheduledWorker({
tenancy,
ruleId: options.ruleId,
cursor: options.cursor,
limit: options.limit,
scheduledAt: options.scheduledAt,
now: options.now,
});
}
async function runAutomationRuleForScheduledWorker(options: {
tenancy: Tenancy,
ruleId: string,
cursor: string | null,
@@ -334,7 +343,8 @@ async function runAutomationRuleForScheduledWorker(options: {
scheduledAt: Date,
now: Date,
}): Promise<AutomationRunResult> {
const prisma = await getPrismaClientForTenancy(options.tenancy);
const tenancy = options.tenancy;
const prisma = await getPrismaClientForTenancy(tenancy);
const sourceAdapter = createPaymentsItemQuotaSourceAdapter({
prisma,
projectUserReader: prismaPaymentsItemQuotaProjectUserReader,
@@ -342,7 +352,7 @@ async function runAutomationRuleForScheduledWorker(options: {
});
return await runAutomationRuleForRoute({
tenancy: options.tenancy,
tenancy,
ruleId: options.ruleId,
limit: options.limit,
cursor: options.cursor,
@@ -353,7 +363,7 @@ async function runAutomationRuleForScheduledWorker(options: {
stateStore: createPrismaAutomationRuleExecutionStateStore(prisma),
emailSender: async ({ action, scheduledAt }) => {
await sendEmailToMany({
tenancy: options.tenancy,
tenancy,
recipients: [action.recipient],
tsxSource: action.tsxSource,
extraVariables: action.variables,
@@ -369,14 +379,47 @@ async function runAutomationRuleForScheduledWorker(options: {
});
}
function getScheduledAutomationFlowControlKey(tenancyId: string) {
return `automation-rule-run:${tenancyId}`;
function completeActiveRule(checkpoint: AutomationSchedulerCheckpoint, ruleId: string): AutomationSchedulerCheckpoint {
return {
...checkpoint,
completedRuleCursor: ruleId,
activeRuleId: null,
nextSubjectCursor: null,
};
}
export function getScheduledAutomationDeduplicationKey(options: {
tenancyId: string,
ruleId: string,
cursor: string | null,
}) {
return `automation-rule-run:${options.tenancyId}:${options.ruleId}:${options.cursor ?? "start"}`;
function completeActiveTenancy(checkpoint: AutomationSchedulerCheckpoint): AutomationSchedulerCheckpoint {
if (checkpoint.activeTenancyId === null) {
throw new Error("Cannot complete an automation scheduler tenancy without an active tenancy.");
}
return {
completedTenancyCursor: checkpoint.activeTenancyId,
activeTenancyId: null,
completedRuleCursor: null,
activeRuleId: null,
nextSubjectCursor: null,
};
}
function emptyCheckpoint(): AutomationSchedulerCheckpoint {
return {
completedTenancyCursor: null,
activeTenancyId: null,
completedRuleCursor: null,
activeRuleId: null,
nextSubjectCursor: null,
};
}
function emptyCronResult(status: "lease-held"): ScheduledAutomationCronResult {
return {
status,
tenanciesScanned: 0,
rulesProcessed: 0,
pagesProcessed: 0,
evaluatedCount: 0,
sentCount: 0,
suppressedCount: 0,
cycleCompleted: false,
};
}
+43 -1
View File
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest';
import { LightEmailTheme } from '@hexclave/shared/dist/helpers/emails';
import { usageEmailTemplateSource } from '../../../../packages/shared/src/helpers/usage-email-template';
import { renderEmailsForTenancyBatched, renderEmailWithTemplate, type RenderEmailRequestForTenancy } from './email-rendering';
describe('renderEmailsForTenancyBatched', () => {
@@ -533,6 +535,47 @@ describe('renderEmailWithTemplate', () => {
}
`;
it.each([
{
thresholdKind: 'near',
currentQuantity: -1,
expectedCopy: 'Your remaining usage is low.',
unexpectedCopy: 'Your quota is at or below its limit.',
},
{
thresholdKind: 'over',
currentQuantity: 999,
expectedCopy: 'Your quota is at or below its limit.',
unexpectedCopy: 'Your remaining usage is low.',
},
])('renders the built-in Usage Email from thresholdKind=$thresholdKind', async ({
thresholdKind,
currentQuantity,
expectedCopy,
unexpectedCopy,
}) => {
const result = await renderEmailWithTemplate(usageEmailTemplateSource, LightEmailTheme, {
user: { displayName: 'QA User' },
project: { displayName: 'QA Project' },
variables: {
projectDisplayName: 'QA Project',
itemDisplayName: 'API requests',
currentQuantity,
thresholdKind,
},
});
expect(result.status).toBe('ok');
if (result.status === 'ok') {
expect(result.data.subject).toBe('API requests usage alert');
expect(result.data.notificationCategory).toBe('Marketing');
expect(result.data.html).toContain(expectedCopy);
expect(result.data.text).toContain(expectedCopy);
expect(result.data.html).not.toContain(unexpectedCopy);
expect(result.data.text).toContain(`Current API requests quantity: ${currentQuantity}`);
}
});
it('preview mode: uses default user and project when not provided', async () => {
const result = await renderEmailWithTemplate(simpleTemplate, simpleTheme, {
previewMode: true,
@@ -685,4 +728,3 @@ describe('renderEmailWithTemplate', () => {
`);
});
});
+7
View File
@@ -1123,6 +1123,13 @@ const organizationConfigDefaults = {
}),
} as const satisfies DefaultsType<OrganizationRenderedConfigBeforeDefaults, [typeof environmentConfigDefaults, typeof branchConfigDefaults, typeof projectConfigDefaults]>;
import.meta.vitest?.test("organization defaults include the built-in Usage Email template", ({ expect }) => {
expect(organizationConfigDefaults.emails.templates["28a45509-eb75-440d-b657-0a8640c775df"]).toMatchObject({
displayName: "Usage Email",
themeId: undefined,
});
});
type _DeepOmitDefaultsImpl<T, U> = T extends object ? (
(
& /* keys that are both in T and U, *and* the key's value in U is not a subtype of the key's value in T */ { [K in { [Ki in keyof T & keyof U]: U[Ki] extends T[Ki] ? never : Ki }[keyof T & keyof U]]: DeepOmitDefaults<T[K], U[K] & object> }
+7
View File
@@ -1,4 +1,5 @@
import { deindent } from "../utils/strings";
import { usageEmailTemplateSource } from "./usage-email-template";
export const defaultNewTemplateSource = deindent`
import { type } from "arktype"
@@ -200,6 +201,7 @@ const EMAIL_TEMPLATE_TEAM_INVITATION_ID = "e84de395-2076-4831-9c19-8e9a96a868e4"
const EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID = "066dd73c-36da-4fd0-b6d6-ebf87683f8bc";
const EMAIL_TEMPLATE_PAYMENT_RECEIPT_ID = "70372aee-0441-4d80-974c-2e858e40123a";
const EMAIL_TEMPLATE_PAYMENT_FAILED_ID = "f64b1afe-27ec-4a28-a277-7178f3261f9a";
const EMAIL_TEMPLATE_USAGE_EMAIL_ID = "28a45509-eb75-440d-b657-0a8640c775df";
export const DEFAULT_EMAIL_TEMPLATES = {
[EMAIL_TEMPLATE_EMAIL_VERIFICATION_ID]: {
@@ -236,6 +238,11 @@ export const DEFAULT_EMAIL_TEMPLATES = {
"displayName": "Payment Failed",
"tsxSource": "import { type } from \"arktype\";\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\nexport const variablesSchema = type({\n productName: \"string\",\n amount: \"string\",\n invoiceUrl: \"string?\",\n failureReason: \"string?\"\n});\n\nexport function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject value={\"Payment failed for \" + project.displayName} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-[1.5] m-0 py-8 w-full min-h-full\">\n <Section className=\"bg-white\">\n <h3 className=\"text-black font-sans font-bold text-[20px] text-center py-4 px-6 m-0\">\n We couldn't process your payment\n </h3>\n <p className=\"text-[#474849] font-sans font-normal text-[14px] text-center pt-2 px-6 pb-4 m-0\">\n Hi{user.displayName ? (\", \" + user.displayName) : \"\"}! Your payment for {variables.productName} ({variables.amount}) did not go through.\n </p>\n {variables.failureReason ? (\n <p className=\"text-[#8a8b8b] font-sans text-[12px] text-center px-6 pb-2 m-0\">\n Reason: {variables.failureReason}\n </p>\n ) : null}\n {variables.invoiceUrl ? (\n <div className=\"text-center py-3 px-6\">\n <Button\n href={variables.invoiceUrl}\n target=\"_blank\"\n className=\"text-black font-sans font-bold text-[14px] inline-block bg-[#f0f0f0] rounded-[4px] py-3 px-5 no-underline border-0\"\n >\n View invoice\n </Button>\n </div>\n ) : null}\n <div className=\"py-4 px-6\">\n <Hr />\n </div>\n <p className=\"text-[#474849] font-sans font-normal text-[12px] text-center pt-1 px-6 pb-6 m-0\">\n Please update your payment details to avoid service interruption.\n </p>\n </Section>\n </div>\n </>\n );\n}\n\nEmailTemplate.PreviewVariables = {\n productName: \"Pro Plan\",\n amount: \"$29.00\",\n invoiceUrl: \"https://example.com/billing\",\n failureReason: \"Your card was declined\"\n} satisfies typeof variablesSchema.infer;\n",
"themeId": undefined,
},
[EMAIL_TEMPLATE_USAGE_EMAIL_ID]: {
"displayName": "Usage Email",
"tsxSource": usageEmailTemplateSource,
"themeId": undefined,
}
};
@@ -0,0 +1,48 @@
export const usageEmailTemplateSource = `
import { type } from "arktype";
import { Heading, Section, Text } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
export const variablesSchema = type({
projectDisplayName: "string",
itemDisplayName: "string",
currentQuantity: "number",
thresholdKind: "'near' | 'over'"
});
export function EmailTemplate({ variables }: Props<typeof variablesSchema.infer>) {
const isOverLimit = variables.thresholdKind === "over";
return (
<>
<Subject value={variables.itemDisplayName + " usage alert"} />
<NotificationCategory value="Marketing" />
<Section>
<Heading as="h3" className="font-sans font-bold text-[20px] text-center py-4 px-6 m-0">
{isOverLimit
? variables.itemDisplayName + " limit reached"
: variables.itemDisplayName + " usage is running low"}
</Heading>
<Text className="font-sans font-normal text-[14px] text-center pt-2 px-6 pb-2 m-0 opacity-80">
{isOverLimit
? "Your quota is at or below its limit."
: "Your remaining usage is low."}
</Text>
<Text className="font-sans font-normal text-[14px] text-center px-6 pb-2 m-0 opacity-80">
Current {variables.itemDisplayName} quantity: {variables.currentQuantity}
</Text>
<Text className="font-sans font-normal text-[12px] text-center px-6 pb-6 m-0 opacity-60">
Review your plan in {variables.projectDisplayName} if you need more capacity.
</Text>
</Section>
</>
);
}
EmailTemplate.PreviewVariables = {
projectDisplayName: "My Project",
itemDisplayName: "API requests",
currentQuantity: 10,
thresholdKind: "near"
} satisfies typeof variablesSchema.infer;
`;