Skip to content

Commit 04e0fe0

Browse files
authored
fix(trigger): let workers see that Trigger.dev is available (#6869)
* fix(trigger): let workers see that Trigger.dev is available Workers run Trigger.dev by definition, but the flag saying so is read from the environment and had only ever been set on the app container. isTriggerAvailable() was therefore false inside every task run, so work a task dispatched silently took the in-process fallback instead of the queue it was written for. Document processing is where this showed: a connector sync chunked and embedded its documents itself, five at a time, rather than handing them to the document-processing queue. The queue's concurrency limit, the per-document task's machine, retry policy and duration budget all sat unused, and a sync with thousands of documents ran until it hit its own max duration. It also explains why that task has no runs for connector-synced knowledge bases at all. Asserting the flag here is safe because the same check still requires TRIGGER_SECRET_KEY, which only the Trigger.dev runtime provides: anywhere dispatching is not actually possible the flag stays ineffective and behaviour is unchanged. Dispatch failure is now recoverable rather than silent. Only a total failure raised before, so one failed batch left its documents at pending with nothing recording why. Those are processed in-process instead, which costs the caller the time it hoped to hand to the queue but does not drop the work. That path was unreachable from a worker until this change made dispatching happen there. * refactor: tighten the comments on this change and the quota classification Both sets explained the incident that motivated the code rather than the code itself. That kind of narrative stops being true as the surrounding system moves and starts misleading instead, so each is cut back to the reason a reader needs.
1 parent fcea50d commit 04e0fe0

4 files changed

Lines changed: 35 additions & 24 deletions

File tree

apps/sim/lib/embeddings/client.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -823,11 +823,8 @@ describe('knowledge embedding transport fallback', () => {
823823
})
824824

825825
/**
826-
* OpenAI returns 429 for an exhausted balance as well as for a rate limit, but
827-
* only one of them reopens. Retrying a spent account cannot succeed, and since
828-
* the sweep re-queues failed documents every sync it turns into permanent load
829-
* — this was observed burning every attempt on thousands of documents for
830-
* weeks against an account with no credit.
826+
* A spent account never reopens, and the sweep re-queues failed documents every
827+
* sync — so retrying one burns the budget per document, indefinitely.
831828
*/
832829
it('does not retry a 429 that reports an exhausted balance', async () => {
833830
vi.useFakeTimers()

apps/sim/lib/embeddings/client.ts

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,7 @@ const EMBEDDING_RETRY_BUDGET_MS = EMBEDDING_MAX_RETRIES * EMBEDDING_MAX_RETRY_DE
7979
export class EmbeddingAPIError extends Error {
8080
public status: number
8181

82-
/**
83-
* The provider rejected this for an exhausted balance rather than a rate that
84-
* will recover. Both arrive as 429.
85-
*/
82+
/** Rejected for an exhausted balance rather than a recoverable rate. Both are 429. */
8683
public quotaExhausted?: boolean
8784

8885
/**
@@ -100,13 +97,8 @@ export class EmbeddingAPIError extends Error {
10097

10198
/**
10299
* True when a rejection body reports an exhausted balance rather than a rate
103-
* limit.
104-
*
105-
* OpenAI returns 429 for both, but only one of them reopens. `insufficient_quota`
106-
* stands until somebody adds credit, so retrying it cannot succeed no matter how
107-
* long the loop waits — and because a failed document is re-queued by the sweep
108-
* on every sync, an account that has run out turns into a permanent load: the
109-
* budget is spent per document, per attempt, forever.
100+
* limit. OpenAI returns 429 for both, but only a rate limit reopens: a spent
101+
* account stands until someone adds credit, so retrying it cannot succeed.
110102
*/
111103
function isQuotaExhaustionBody(errorText: string): boolean {
112104
try {
@@ -148,13 +140,10 @@ function statedWaitOutlastsBudget(error: unknown): boolean {
148140
}
149141

150142
/**
151-
* Whether another attempt against the same provider could plausibly succeed.
152-
*
153-
* Deliberately narrower than {@link isTransientEmbeddingError}, which also
154-
* decides whether the fallback chain should try a *different* provider. Those
155-
* two questions differ: an exhausted balance rules out the key we just used, but
156-
* says nothing about the next one in the chain, so a quota rejection stops the
157-
* retries here while remaining eligible for failover.
143+
* Whether another attempt against the *same* provider could succeed. Narrower
144+
* than {@link isTransientEmbeddingError}, which decides whether to fail over to a
145+
* different one: an exhausted balance rules out the key just used but says
146+
* nothing about the next in the chain.
158147
*/
159148
function isWorthRetrying(error: unknown): boolean {
160149
if (!isTransientEmbeddingError(error)) return false

apps/sim/lib/knowledge/documents/service.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,7 @@ async function dispatchViaBatchTrigger(
721721
): Promise<number> {
722722
let dispatched = 0
723723
const batchIds: string[] = []
724+
const undispatched: DocumentProcessingPayload[] = []
724725
const region = await resolveTriggerRegion()
725726
for (let i = 0; i < jobPayloads.length; i += TRIGGER_BATCH_SIZE) {
726727
const chunk = jobPayloads.slice(i, i + TRIGGER_BATCH_SIZE)
@@ -747,11 +748,25 @@ async function dispatchViaBatchTrigger(
747748
logger.error(`[${requestId}] Failed to batchTrigger ${chunk.length} document jobs`, {
748749
error: getErrorMessage(error),
749750
})
751+
undispatched.push(...chunk)
750752
}
751753
}
752754
if (batchIds.length > 0) {
753755
logger.info(`[${requestId}] Trigger.dev batches dispatched`, { batchIds })
754756
}
757+
758+
/**
759+
* Only a total dispatch failure raises, so a chunk failing alone would leave its
760+
* documents at `pending` with nothing recording why. Processing them here is
761+
* slower than the queue but does not drop the work.
762+
*/
763+
if (undispatched.length > 0) {
764+
logger.warn(
765+
`[${requestId}] Processing ${undispatched.length} documents in-process after failed enqueue`
766+
)
767+
dispatched += await dispatchInProcess(undispatched, requestId)
768+
}
769+
755770
return dispatched
756771
}
757772

apps/sim/trigger.config.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,17 @@ export default defineConfig({
7373
'@daytona/sdk',
7474
],
7575
extensions: [
76-
syncEnvVars(() => [{ name: 'DB_APP_NAME', value: 'sim-trigger' }]),
76+
syncEnvVars(() => [
77+
{ name: 'DB_APP_NAME', value: 'sim-trigger' },
78+
/**
79+
* Workers run Trigger.dev by definition, but the flag saying so was only
80+
* set on the app container, so `isTriggerAvailable()` was false in every
81+
* task run and dispatched work silently took the in-process fallback.
82+
* Ineffective where dispatching is impossible: the check also requires
83+
* TRIGGER_SECRET_KEY, which only the Trigger.dev runtime provides.
84+
*/
85+
{ name: 'TRIGGER_DEV_ENABLED', value: 'TRUE' },
86+
]),
7787
additionalFiles({
7888
files: [
7989
'./lib/execution/isolated-vm-worker.cjs',

0 commit comments

Comments
 (0)