Skip to content

Commit ee5e5da

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat(convex): on-demand run synchronization to the integration
Add on-demand synchronization to `@trigger.dev/convex` with `trigger.refresh(ctx, componentRunId)`. Applications can use Trigger Realtime for responsive progress, then request an earlier authoritative status check when Realtime reports completion. The component keeps its existing polling and watchdog as recovery when no browser is connected. - Refresh requests are scheduled asynchronously and coalesced per run. - Refresh-driven status checks start at least five seconds after the previous status check. Hints during an in-flight request queue one follow-up without discarding that response. - Pending submissions, paused synchronization, and completed records are no-ops. Refresh does not trigger another task, reset retry failures, or resume paused work. - Existing terminal-state handling, transactional completion callbacks, output budgets, generation fencing, and request/credential cleanup are reused. - Optional internal coordination fields require no record migration and are excluded from the public run shape. - README guidance includes an ownership-checked mutation, the Realtime/polling split, and the warning that Realtime column exclusions do not prevent a run-read token from retrieving payloads through REST. No Trigger server changes, persistent subscriptions, new dependencies, or token-issuing API are added. Mono-RevId: 91a3884a75fe0972740ee42cd6094a82ae036c63
1 parent daaaa41 commit ee5e5da

8 files changed

Lines changed: 276 additions & 19 deletions

File tree

‎.changeset/convex-refresh.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/convex": patch
3+
---
4+
5+
Add `trigger.refresh(ctx, id)` to request an earlier status check for a Convex-tracked run, so applications using Trigger Realtime can publish results without waiting for the next scheduled poll. Requests are coalesced and rate-limited, use the existing completion callback, and retain polling as a recovery fallback.

‎packages/convex/README.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ The ID returned by `trigger.trigger` identifies the **component record**, not th
124124
- `trigger(ctx, taskId, payload, options?)`: enqueue work. Calling from a mutation makes the request atomic with your application's writes.
125125
- `getRun(ctx, id)`: read one record reactively.
126126
- `listRuns(ctx, paginationOpts)`: paginate records, newest first, up to 100 per page and a bounded read budget.
127+
- `refresh(ctx, id)`: schedule an earlier status check for an active, submitted run. Returns `null` after scheduling, not the updated run.
127128
- `resume(ctx, id)`: resume paused synchronization using the current credentials.
128129

129130
Trigger options include tags, delay, queue name, concurrency key, machine, metadata, max attempts, max duration, TTL, and a component-level idempotency key.
@@ -134,6 +135,35 @@ The constructor accepts `secretKey` (defaults to `TRIGGER_SECRET_KEY`), `baseUrl
134135

135136
**Authorization belongs in your application's functions.** The component has no tenant or user model. Do not expose arbitrary record IDs or `listRuns` to clients without enforcing ownership. Queries never return stored credentials or request payloads.
136137

138+
## Realtime progress and faster result delivery
139+
140+
Use [Trigger Realtime](https://trigger.dev/docs/realtime/overview) for responsive browser progress, and Convex queries for durable application state. When Realtime reports that a run is complete, call an ownership-checked application mutation that requests a component refresh:
141+
142+
```ts
143+
// convex/jobs.ts
144+
export const refresh = mutation({
145+
args: { jobId: v.id("jobs") },
146+
handler: async (ctx, { jobId }) => {
147+
const user = await ctx.auth.getUserIdentity();
148+
const job = await ctx.db.get(jobId);
149+
if (!user || !job || job.owner !== user.subject) throw new Error("Unauthorized");
150+
if (job.runId) await trigger.refresh(ctx, job.runId);
151+
},
152+
});
153+
```
154+
155+
Pass the component record ID to `refresh`, not the remote Trigger run ID. Your browser subscribes to the latter, available as `run.runId` from `getRun`.
156+
157+
A refresh is only a hint: the component retrieves status and output directly from Trigger and uses the same validation, completion callback, and credential cleanup as normal polling. It never accepts client-supplied status or output. Observe `getRun` or your application query for the persisted result; awaiting `refresh` does not wait for that result.
158+
159+
- Refresh requests are coalesced per run. Status checks requested this way start at least five seconds after the previous status check.
160+
- A hint received during a check queues one follow-up instead of interrupting or discarding the in-flight response. If that response completes the run, no follow-up is needed.
161+
- Pending submissions, paused synchronization, and completed records are no-ops. Refresh never submits a task or resumes paused work; use `resume` after repairing a paused synchronization.
162+
- Call refresh when Realtime observes completion, including an already-completed run after reconnecting. Do not call it on every metadata update to animate progress.
163+
- Scheduled polling and its watchdog remain enabled. Convex can still finish the run if the browser closes, the subscription disconnects, or the refresh request fails. No persistent subscription inside a Convex action is required.
164+
165+
**Realtime token safety:** authenticate the token request, verify job ownership, and issue a short-lived token scoped to the exact remote run. Keep the root Trigger key server-side. A run-read token can also read the run through REST, including its payload; Realtime `skipColumns` is not a restriction on REST access. Keep worker credentials, storage-upload capabilities, and other secrets out of token-readable payloads, outputs, metadata, and errors. Application mutations must enforce ownership even when component refreshes are coalesced.
166+
137167
## Execution and reliability
138168

139169
1. The calling mutation writes a component record and schedules submission. If the transaction rolls back, neither survives.

‎packages/convex/src/client/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ export class Trigger {
7575
return ctx.runQuery(this.component.runs.list, { paginationOpts });
7676
}
7777

78+
/**
79+
* Request an earlier status check without accepting client-supplied run data.
80+
* The check is scheduled, coalesced, and rate-limited; observe getRun for its result.
81+
*/
82+
async refresh(ctx: MutationCtx, id: string): Promise<null> {
83+
return ctx.runMutation(this.component.runs.refresh, { id });
84+
}
85+
7886
async resume(ctx: MutationCtx, id: string): Promise<null> {
7987
return ctx.runMutation(this.component.runs.resume, { id, config: this.runtimeConfig() });
8088
}

‎packages/convex/src/component/runs.test.ts‎

Lines changed: 162 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { defineSchema, defineTable } from "convex/server";
66
import { v } from "convex/values";
77
import { Trigger, vRun, type ComponentApi } from "../client/index.js";
88
import schema from "./schema.js";
9-
import { MAX_INLINE_BYTES } from "./shared.js";
9+
import { MAX_INLINE_BYTES, MIN_REFRESH_INTERVAL_MS } from "./shared.js";
1010

1111
const modules = {
1212
"./_generated/component.ts": () => import("./_generated/component.js"),
@@ -63,6 +63,10 @@ const { createTest, api, internal } = app.defineModules({
6363
return id;
6464
},
6565
}),
66+
refresh: app.mutation({
67+
args: { id: v.string() },
68+
handler: async (ctx, { id }) => new Trigger(app.components.trigger).refresh(ctx, id),
69+
}),
6670
},
6771
});
6872

@@ -92,6 +96,8 @@ let remoteStatus: string;
9296
let remoteIsCompleted: boolean;
9397
let largeOutput: boolean;
9498
let submittedOptions: Record<string, unknown>;
99+
let holdNextPoll: boolean;
100+
let releasePoll: (() => void) | undefined;
95101

96102
beforeAll(async () => {
97103
server = createServer(async (req, res) => {
@@ -125,19 +131,26 @@ beforeAll(async () => {
125131
return;
126132
}
127133
const completed = activePolls-- <= 0;
128-
res.end(
129-
JSON.stringify({
130-
id: "run_1",
131-
status: completed ? remoteStatus : "EXECUTING",
132-
isCompleted: completed && remoteIsCompleted,
133-
output: completed
134-
? largeOutput
135-
? "x".repeat(MAX_INLINE_BYTES + 1)
136-
: { url: "https://example.com/video.mp4" }
137-
: undefined,
138-
metadata: { progress: completed ? 100 : 50 },
139-
})
140-
);
134+
const body = JSON.stringify({
135+
id: "run_1",
136+
status: completed ? remoteStatus : "EXECUTING",
137+
isCompleted: completed && remoteIsCompleted,
138+
output: completed
139+
? largeOutput
140+
? "x".repeat(MAX_INLINE_BYTES + 1)
141+
: { url: "https://example.com/video.mp4" }
142+
: undefined,
143+
metadata: { progress: completed ? 100 : 50 },
144+
});
145+
if (holdNextPoll) {
146+
holdNextPoll = false;
147+
releasePoll = () => {
148+
if (!res.writableEnded) res.end(body);
149+
releasePoll = undefined;
150+
};
151+
} else {
152+
res.end(body);
153+
}
141154
}
142155
});
143156
server.listen(0, "127.0.0.1");
@@ -162,8 +175,13 @@ beforeEach(() => {
162175
remoteIsCompleted = true;
163176
largeOutput = false;
164177
submittedOptions = {};
178+
holdNextPoll = false;
179+
releasePoll = undefined;
180+
});
181+
afterEach(() => {
182+
releasePoll?.();
183+
vi.useRealTimers();
165184
});
166-
afterEach(() => vi.useRealTimers());
167185
afterAll(
168186
() =>
169187
new Promise<void>((resolve, reject) => {
@@ -344,6 +362,135 @@ test("a failing callback rolls back completion and can be retried without duplic
344362
expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(1);
345363
});
346364

365+
async function runScheduled(t: ReturnType<typeof createTest>, advanceMs = 0): Promise<void> {
366+
// Convex timestamps have sub-millisecond precision, so zero-delay jobs may fall just after now.
367+
vi.advanceTimersByTime(advanceMs + 1);
368+
await t.finishInProgressScheduledFunctions();
369+
vi.advanceTimersByTime(1);
370+
await t.finishInProgressScheduledFunctions();
371+
}
372+
373+
// Catches: refresh bursts bypassing the check cooldown or racing the polling callback.
374+
test("refreshes promptly, bounds repeated checks, and completes once alongside polling", async () => {
375+
activePolls = 1;
376+
const t = createTest();
377+
const id = await t.mutation(api.jobs.request, {});
378+
await runScheduled(t);
379+
expect(submissions).toBe(1);
380+
expect(polls).toBe(0);
381+
382+
await Promise.all(Array.from({ length: 3 }, () => t.mutation(api.jobs.refresh, { id })));
383+
await runScheduled(t);
384+
expect(polls).toBe(1);
385+
expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
386+
status: "EXECUTING",
387+
metadata: { progress: 50 },
388+
});
389+
390+
await Promise.all(Array.from({ length: 3 }, () => t.mutation(api.jobs.refresh, { id })));
391+
await runScheduled(t, MIN_REFRESH_INTERVAL_MS / 2);
392+
expect(polls).toBe(1);
393+
await runScheduled(t, MIN_REFRESH_INTERVAL_MS / 2);
394+
expect(polls).toBe(2);
395+
expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
396+
status: "COMPLETED",
397+
syncStatus: "complete",
398+
output: { url: "https://example.com/video.mp4" },
399+
});
400+
await t.mutation(api.jobs.refresh, { id });
401+
await drain(t);
402+
expect(polls).toBe(2);
403+
expect(submissions).toBe(1);
404+
expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(1);
405+
});
406+
407+
// Catches: refresh fencing out an in-flight observation or losing its completion hint.
408+
test("coalesces hints during an in-flight request and follows it with another check", async () => {
409+
activePolls = 1;
410+
const t = createTest();
411+
const id = await t.mutation(api.jobs.request, {});
412+
await runScheduled(t);
413+
holdNextPoll = true;
414+
const received = once(server, "request");
415+
await t.mutation(api.jobs.refresh, { id });
416+
vi.advanceTimersByTime(1);
417+
await t.finishInProgressScheduledFunctions();
418+
vi.advanceTimersByTime(1);
419+
const checking = t.finishInProgressScheduledFunctions();
420+
await received;
421+
try {
422+
await t.mutation(api.jobs.refresh, { id });
423+
await t.mutation(api.jobs.refresh, { id });
424+
vi.advanceTimersByTime(MIN_REFRESH_INTERVAL_MS);
425+
expect(polls).toBe(1);
426+
releasePoll!();
427+
await checking;
428+
expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
429+
status: "EXECUTING",
430+
syncStatus: "active",
431+
metadata: { progress: 50 },
432+
});
433+
await runScheduled(t);
434+
expect(polls).toBe(2);
435+
expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
436+
status: "COMPLETED",
437+
syncStatus: "complete",
438+
});
439+
expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(1);
440+
} finally {
441+
releasePoll?.();
442+
await checking;
443+
}
444+
});
445+
446+
// Catches: a failed status request leaving refresh permanently blocked as in-flight.
447+
test("allows a refresh after a retryable status error", async () => {
448+
pollFailures = 1;
449+
const t = createTest();
450+
const id = await t.mutation(api.jobs.request, {});
451+
await runScheduled(t);
452+
await t.mutation(api.jobs.refresh, { id });
453+
await runScheduled(t);
454+
expect(polls).toBe(1);
455+
expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
456+
syncStatus: "active",
457+
});
458+
await t.mutation(api.jobs.refresh, { id });
459+
await runScheduled(t, MIN_REFRESH_INTERVAL_MS);
460+
expect(polls).toBe(2);
461+
expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
462+
syncStatus: "complete",
463+
});
464+
});
465+
466+
// Catches: browser hints resubmitting pending work or bypassing a permanent synchronization failure.
467+
test("does not submit pending work early or resume paused synchronization", async () => {
468+
pollFailures = 1;
469+
pollFailureStatus = 401;
470+
const t = createTest();
471+
const id = await t.mutation(api.jobs.request, {});
472+
await t.mutation(api.jobs.refresh, { id });
473+
expect(submissions).toBe(0);
474+
await runScheduled(t);
475+
await t.mutation(api.jobs.refresh, { id });
476+
await runScheduled(t);
477+
expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
478+
syncStatus: "paused",
479+
});
480+
await t.mutation(api.jobs.refresh, { id });
481+
await runScheduled(t, MIN_REFRESH_INTERVAL_MS);
482+
expect(polls).toBe(1);
483+
expect(submissions).toBe(1);
484+
await t.mutation(app.components.trigger.runs.resume, {
485+
id,
486+
config: { secretKey: "test-only-key", baseUrl },
487+
});
488+
await drain(t);
489+
expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
490+
syncStatus: "complete",
491+
});
492+
});
493+
347494
test("rejects large inputs before scheduling remote work", async () => {
348495
const t = createTest();
349496
await expect(

‎packages/convex/src/component/runs.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { internalMutation, internalQuery, mutation, query, type DataModel } from
99
import {
1010
MAX_INLINE_BYTES,
1111
MAX_PAGE_BYTES,
12+
MIN_REFRESH_INTERVAL_MS,
1213
SUBMISSION_WINDOW_MS,
1314
vConfig,
1415
vRun,
@@ -19,7 +20,18 @@ import {
1920
type RunDoc = DataModel["runs"]["document"];
2021

2122
export function toRun(run: RunDoc): Run {
22-
const { _id, _creationTime, generation, checks, failures, callback, ...fields } = run;
23+
const {
24+
_id,
25+
_creationTime,
26+
generation,
27+
checks,
28+
failures,
29+
callback,
30+
checking,
31+
refreshRequested,
32+
lastCheckAt,
33+
...fields
34+
} = run;
2335
return { id: _id, createdAt: _creationTime, ...fields };
2436
}
2537

@@ -110,6 +122,36 @@ export const list = query({
110122
},
111123
});
112124

125+
export async function scheduleRefresh(
126+
ctx: GenericMutationCtx<DataModel>,
127+
run: RunDoc
128+
): Promise<void> {
129+
if (!run.refreshRequested) return;
130+
const delay =
131+
run.lastCheckAt === undefined
132+
? 0
133+
: Math.max(0, run.lastCheckAt + MIN_REFRESH_INTERVAL_MS - Date.now());
134+
await ctx.scheduler.runAfter(delay, internal.worker.tick, {
135+
id: run._id,
136+
generation: run.generation,
137+
});
138+
}
139+
140+
export const refresh = mutation({
141+
args: { id: v.string() },
142+
returns: v.null(),
143+
handler: async (ctx, { id }): Promise<null> => {
144+
const runId = ctx.db.normalizeId("runs", id);
145+
const run = runId ? await ctx.db.get("runs", runId) : null;
146+
if (!run) throw new Error("Run not found");
147+
if (!run.runId || run.syncStatus !== "active" || run.refreshRequested) return null;
148+
await ctx.db.patch(run._id, { refreshRequested: true });
149+
// Preserve the in-flight observation; its result will schedule any requested follow-up.
150+
if (!run.checking) await scheduleRefresh(ctx, { ...run, refreshRequested: true });
151+
return null;
152+
},
153+
});
154+
113155
export const resume = mutation({
114156
args: { id: v.string(), config: vConfig },
115157
returns: v.null(),
@@ -136,6 +178,8 @@ export const resume = mutation({
136178
failures: 0,
137179
checks: 0,
138180
generation,
181+
checking: undefined,
182+
refreshRequested: undefined,
139183
updatedAt: Date.now(),
140184
});
141185
await ctx.scheduler.runAfter(0, internal.worker.tick, { id: run._id, generation });
@@ -168,6 +212,7 @@ export const recordSubmission = internalMutation({
168212
syncError: undefined,
169213
failures: 0,
170214
checks: 0,
215+
checking: undefined,
171216
updatedAt: Date.now(),
172217
});
173218
return null;
@@ -195,6 +240,8 @@ export const recordStatus = internalMutation({
195240
syncStatus: completed ? ("complete" as const) : ("active" as const),
196241
syncError: undefined,
197242
failures: 0,
243+
checking: undefined,
244+
refreshRequested: completed ? undefined : run.refreshRequested,
198245
updatedAt: Date.now(),
199246
};
200247
await ctx.db.patch("runs", id, updates);
@@ -209,6 +256,8 @@ export const recordStatus = internalMutation({
209256
.withIndex("by_run", (q) => q.eq("run", id))
210257
.unique();
211258
if (request) await ctx.db.delete("requests", request._id);
259+
} else {
260+
await scheduleRefresh(ctx, run);
212261
}
213262
return null;
214263
},
@@ -222,6 +271,8 @@ export async function pause(
222271
await ctx.db.patch("runs", run._id, {
223272
syncStatus: "paused",
224273
syncError: message,
274+
checking: undefined,
275+
refreshRequested: undefined,
225276
updatedAt: Date.now(),
226277
});
227278
}

0 commit comments

Comments
 (0)