Skip to content

Commit 3d54889

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp,webhook-engine,redis-worker): queue-first webhook ingest
Webhook deliveries are acknowledged only once they are durably queued, and the queued job records the delivery, so an acknowledged delivery can no longer be lost or left pending, and a provider retry of a delivery that was cut off mid-way is processed instead of dropped. The webhooks Redis is now one cluster connection configured with `WEBHOOK_REDIS_HOST`, `WEBHOOK_REDIS_PORT`, `WEBHOOK_REDIS_USERNAME`, `WEBHOOK_REDIS_PASSWORD` and `WEBHOOK_REDIS_TLS_DISABLED`, replacing the separate worker and waiter Redis settings. When an environment has too many deliveries waiting, ingest answers 503 with a `Retry-After` header so the provider retries later. Ingress refusals now close the connection, so a provider's next request on a kept-alive connection no longer hangs after a refused one. Mono-RevId: 0b649edbe0287a4b6ca6126bdc256df884297e0d
1 parent a045ff8 commit 3d54889

46 files changed

Lines changed: 2925 additions & 641 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.schema‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ REALTIME_STREAMS_DEFAULT_VERSION=v2
112112
# OTEL_PROMETHEUS_HOST_PORT=18889 # default 8889
113113
# PROMETHEUS_HOST_PORT=19090 # default 9090
114114
# GRAFANA_HOST_PORT=13001 # default 3001
115+
# --- valkey cluster (only needed with COMPOSE_PROFILES=valkey-cluster) ---
116+
# VALKEY_CLUSTER_1_HOST_PORT=17101 # default 7101, and 7102..7106 for _2.._6
115117
# (and update DATABASE_URL / CLICKHOUSE_URL / REDIS_PORT / APP_ORIGIN /
116118
# LOGIN_ORIGIN / ELECTRIC_ORIGIN / REALTIME_STREAMS_S2_ENDPOINT to match)
117119

‎apps/webapp/app/env.server.ts‎

Lines changed: 9 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1940,67 +1940,17 @@ const EnvironmentSchema = z
19401940
WEBHOOK_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().positive().default(20),
19411941
WEBHOOK_WORKER_TENANT_CONCURRENCY: z.coerce.number().int().positive().default(100),
19421942
WEBHOOK_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
1943-
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(30_000),
19441943
WEBHOOK_ENABLED: z.string().default("0"),
19451944

1946-
WEBHOOK_WORKER_REDIS_HOST: z
1947-
.string()
1948-
.optional()
1949-
.transform((v) => v ?? process.env.REDIS_HOST),
1950-
WEBHOOK_WORKER_REDIS_PORT: z.coerce
1951-
.number()
1952-
.optional()
1953-
.transform(
1954-
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
1955-
),
1956-
WEBHOOK_WORKER_REDIS_USERNAME: z
1957-
.string()
1958-
.optional()
1959-
.transform((v) => v ?? process.env.REDIS_USERNAME),
1960-
WEBHOOK_WORKER_REDIS_PASSWORD: z
1961-
.string()
1962-
.optional()
1963-
.transform((v) => v ?? process.env.REDIS_PASSWORD),
1964-
WEBHOOK_WORKER_REDIS_TLS_DISABLED: z
1965-
.string()
1966-
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
1967-
1968-
// Webhook waiter store (also the ingest front gate). Falls back to the webhook worker Redis.
1969-
// Cluster mode connects to the host/port as a cluster seed node.
1970-
WEBHOOK_WAITER_REDIS_HOST: z
1971-
.string()
1972-
.optional()
1973-
.transform((v) => v ?? process.env.WEBHOOK_WORKER_REDIS_HOST ?? process.env.REDIS_HOST),
1974-
WEBHOOK_WAITER_REDIS_PORT: z.coerce
1975-
.number()
1976-
.optional()
1977-
.transform(
1978-
(v) =>
1979-
v ??
1980-
(process.env.WEBHOOK_WORKER_REDIS_PORT
1981-
? parseInt(process.env.WEBHOOK_WORKER_REDIS_PORT)
1982-
: process.env.REDIS_PORT
1983-
? parseInt(process.env.REDIS_PORT)
1984-
: undefined)
1985-
),
1986-
WEBHOOK_WAITER_REDIS_USERNAME: z
1987-
.string()
1988-
.optional()
1989-
.transform(
1990-
(v) => v ?? process.env.WEBHOOK_WORKER_REDIS_USERNAME ?? process.env.REDIS_USERNAME
1991-
),
1992-
WEBHOOK_WAITER_REDIS_PASSWORD: z
1993-
.string()
1994-
.optional()
1995-
.transform(
1996-
(v) => v ?? process.env.WEBHOOK_WORKER_REDIS_PASSWORD ?? process.env.REDIS_PASSWORD
1997-
),
1998-
WEBHOOK_WAITER_REDIS_TLS_DISABLED: z
1999-
.string()
2000-
.default(
2001-
process.env.WEBHOOK_WORKER_REDIS_TLS_DISABLED ?? process.env.REDIS_TLS_DISABLED ?? "false"
2002-
),
2003-
WEBHOOK_WAITER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
1945+
// The webhooks Redis: a cluster (durable in production), reached through any node as a seed.
1946+
// It holds the job queue, the ingest front gate, waiters and session append claims.
1947+
WEBHOOK_REDIS_HOST: z.string().optional(),
1948+
WEBHOOK_REDIS_PORT: z.coerce.number().optional(),
1949+
WEBHOOK_REDIS_USERNAME: z.string().optional(),
1950+
WEBHOOK_REDIS_PASSWORD: z.string().optional(),
1951+
WEBHOOK_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
1952+
// The share of a primary's maxmemory past which ingest refuses deliveries whose jobs live on it.
1953+
WEBHOOK_QUEUE_HIGH_WATER: z.coerce.number().positive().max(1).default(0.75),
20041954
WEBHOOK_WAITER_MAX_PER_ENVIRONMENT: z.coerce.number().int().positive().default(1_000_000),
20051955
WEBHOOK_WAITER_MAX_PER_ENDPOINT: z.coerce.number().int().positive().default(10_000),
20061956
WEBHOOK_WAITER_MAX_SHAPES: z.coerce.number().int().positive().default(25),

‎apps/webapp/app/routes/api.v1.webhooks.deliveries.$deliveryId.replay.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ const { action, loader } = createActionApiRoute(
8181
{ error: `The endpoint has no subscriber "${searchParams.targetId}".` },
8282
{ status: 404 }
8383
);
84+
case "queue_full":
85+
return json(
86+
{ error: "This environment has too many webhook deliveries waiting. Try again later." },
87+
{ status: 503, headers: { "Retry-After": "60" } }
88+
);
8489
}
8590
}
8691
);

‎apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam.send.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,10 @@ export async function action({ request, params }: ActionFunctionArgs): Promise<W
215215
switch (result.outcome) {
216216
case "accepted": {
217217
const friendlyId = result.deliveryFriendlyId;
218-
if (shouldRedirect) throw redirect(deliveryPathFor(friendlyId));
218+
if (shouldRedirect) {
219+
await webhookEngine.waitForDeliveryRow(result.deliveryId, 3_000);
220+
throw redirect(deliveryPathFor(friendlyId));
221+
}
219222
return {
220223
success: true,
221224
httpStatus: answer.status,
@@ -260,6 +263,14 @@ export async function action({ request, params }: ActionFunctionArgs): Promise<W
260263
return { success: false, error: "This endpoint is not active." };
261264
case "enqueue_failed":
262265
return { success: false, error: result.error ?? "Failed to record the delivery." };
266+
case "queue_full":
267+
case "over_capacity":
268+
return {
269+
success: false,
270+
error: "Too many webhook deliveries are waiting in this environment. Try again shortly.",
271+
httpStatus: answer.status,
272+
responseBody: answer.body ?? "",
273+
};
263274
default:
264275
return { success: false, error: "The delivery could not be sent." };
265276
}

‎apps/webapp/app/routes/webhooks.v1.ingest.$opaqueId.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ export async function action({ request, params }: ActionFunctionArgs) {
6161
case "enqueue_failed":
6262
logger.error("webhook ingress enqueue failed", { opaqueId, error: result.error });
6363
break;
64+
case "queue_full":
65+
case "over_capacity":
66+
logger.warn("webhook ingress refused: queue at capacity", {
67+
opaqueId,
68+
reason: result.outcome,
69+
});
70+
break;
6471
default:
6572
break;
6673
}

‎apps/webapp/app/services/realtime/s2realtimeStreams.server.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,10 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
644644
lastError = new Error(`S2 append failed: 409 ${res.statusText} ${text}`.trim());
645645
} else {
646646
await cancelResponseBody(res);
647-
const httpError = new Error(`S2 append failed: ${res.status} ${res.statusText}`);
647+
const httpError = Object.assign(
648+
new Error(`S2 append failed: ${res.status} ${res.statusText}`),
649+
{ status: res.status }
650+
);
648651
if (res.status >= 400 && res.status < 500) {
649652
throw httpError;
650653
}

‎apps/webapp/app/v3/commonWorker.server.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { BulkActionService } from "./services/bulk/BulkActionV2.server";
3131
import { prisma } from "~/db.server";
3232
import { sweepPreviewAutoArchives } from "~/services/previewAutoArchive.server";
3333
import { controlPlaneResolver } from "./runOpsMigration/controlPlaneResolver.server";
34+
import { webhookEngine } from "./webhookEngine.server";
3435

3536
function initializeWorker() {
3637
const redisOptions = {
@@ -54,6 +55,17 @@ function initializeWorker() {
5455
name: "common-worker",
5556
redisOptions,
5657
catalog: {
58+
"webhooks.ensurePartitions": {
59+
schema: CronSchema,
60+
visibilityTimeoutMs: 60_000 * 5,
61+
...(env.WEBHOOK_ENABLED === "1"
62+
? {
63+
cron: env.WEBHOOK_PARTITION_ENSURE_SCHEDULE ?? "17 3 * * *",
64+
jitterInMs: env.WEBHOOK_PARTITION_ENSURE_JITTER_MS ?? 5 * 60_000,
65+
}
66+
: {}),
67+
retry: { maxAttempts: 1 },
68+
},
5769
"previewBranches.autoArchiveSweep": {
5870
schema: CronSchema,
5971
cron: "* * * * *",
@@ -204,6 +216,9 @@ function initializeWorker() {
204216
shutdownTimeoutMs: env.COMMON_WORKER_SHUTDOWN_TIMEOUT_MS,
205217
logger: new Logger("CommonWorker", env.COMMON_WORKER_LOG_LEVEL),
206218
jobs: {
219+
"webhooks.ensurePartitions": async () => {
220+
await webhookEngine.ensurePartitions();
221+
},
207222
"previewBranches.autoArchiveSweep": async () => {
208223
await sweepPreviewAutoArchives(prisma, (ids) => {
209224
for (const id of ids) controlPlaneResolver.invalidateEnvironment(id);

0 commit comments

Comments
 (0)