Skip to content

Commit d602fff

Browse files
committed
fix(webapp): limit convergence compares enforced values, zero thresholds never read as at-limit
The freshness re-check after an override sync converges on the values the engine enforces instead of updatedAt, since Prisma's millisecond updatedAt makes two same-millisecond writes indistinguishable; identical values mean identical engine state so skipping them is safe. A zero at-limit threshold (a paused limit, or a queue overridden to 0) no longer shows the at-limit warning, matching the health badge. The override tests reset mocks per test and assert the exact compensating call so they fail without the fix.
1 parent 2a8ef37 commit d602fff

3 files changed

Lines changed: 51 additions & 19 deletions

File tree

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -782,8 +782,12 @@ function QueuesWithMetricsView() {
782782
? Math.min(queue.concurrency.combined.current, environment.concurrencyLimit)
783783
: null
784784
: limit;
785+
/** A zero threshold is a pause (nothing may run), not saturation —
786+
* without the guard `running >= 0` holds for every row. */
785787
const isAtConcurrencyLimit =
786-
atLimitThreshold !== null && queue.running >= atLimitThreshold;
788+
atLimitThreshold !== null &&
789+
atLimitThreshold > 0 &&
790+
queue.running >= atLimitThreshold;
787791
const isAtQueueLimit =
788792
environment.queueSizeLimit !== null &&
789793
queue.queued >= environment.queueSizeLimit;
@@ -1970,8 +1974,12 @@ function ClassicQueuesView() {
19701974
? Math.min(queue.concurrency.combined.current, environment.concurrencyLimit)
19711975
: null
19721976
: limit;
1977+
/** A zero threshold is a pause (nothing may run), not saturation —
1978+
* without the guard `running >= 0` holds for every row. */
19731979
const isAtConcurrencyLimit =
1974-
atLimitThreshold !== null && queue.running >= atLimitThreshold;
1980+
atLimitThreshold !== null &&
1981+
atLimitThreshold > 0 &&
1982+
queue.running >= atLimitThreshold;
19751983
const isAtQueueLimit =
19761984
environment.queueSizeLimit !== null &&
19771985
queue.queued >= environment.queueSizeLimit;

‎apps/webapp/app/v3/services/concurrencyLimitsSystem.server.ts‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,10 @@ export class ConcurrencyLimitsSystem {
128128
syncLimitToEngine(environment, row)
129129
.andThen(() =>
130130
compensateEngineFromFreshRow(this.db, environment, row.id, {
131-
alreadySyncedAt: row.updatedAt.getTime(),
131+
alreadySynced: {
132+
perKey: row.concurrencyLimit,
133+
total: row.totalConcurrencyLimit,
134+
},
132135
})
133136
.orElse(() => okAsync(undefined))
134137
.map(() => row)
@@ -387,28 +390,39 @@ function guardedLimitUpdate(
387390
);
388391
}
389392

393+
type SyncedLimitValues = { perKey: number | null; total: number | null };
394+
390395
/**
391-
* Re-syncs the engine from fresh reads of the row until updatedAt stops moving
392-
* (bounded), the same convergence the deploy sync uses: every actor writes
396+
* Re-syncs the engine from fresh reads of the row until the enforced values stop
397+
* moving (bounded), the same convergence the deploy sync uses: every actor writes
393398
* Postgres before its own engine sync, so re-syncing whatever is freshest
394-
* converges. Callers use it two ways: after a failure (a reset's enforce-first
395-
* engine write preceding a persist that then conflicts, or an override's sync
396-
* failing after its persist), where the original error still reaches the caller;
397-
* and after a successful sync with `alreadySyncedAt` set to the synced row's
398-
* updatedAt, where an unchanged row costs one read and a moved row is re-synced.
399+
* converges. The fixpoint compares the values the engine enforces rather than
400+
* updatedAt, because Prisma's @updatedAt has millisecond precision and two writes
401+
* in the same millisecond are indistinguishable by timestamp; identical values
402+
* mean identical engine state, so skipping those is always safe. Callers use it
403+
* two ways: after a failure (a reset's enforce-first engine write preceding a
404+
* persist that then conflicts, or an override's sync failing after its persist),
405+
* where the original error still reaches the caller; and after a successful sync
406+
* with `alreadySynced` set to the values just synced, where an unchanged row
407+
* costs one read and a moved row is re-synced.
399408
*/
400409
function compensateEngineFromFreshRow(
401410
db: PrismaClientOrTransaction,
402411
environment: AuthenticatedEnvironment,
403412
rowId: string,
404-
options?: { alreadySyncedAt?: number }
413+
options?: { alreadySynced?: SyncedLimitValues }
405414
) {
406415
return fromPromise(
407416
(async () => {
408-
let lastSyncedAt: number | null = options?.alreadySyncedAt ?? null;
417+
let lastSynced: SyncedLimitValues | null = options?.alreadySynced ?? null;
409418
for (let i = 0; i < 3; i++) {
410419
const fresh = await db.taskQueue.findFirst({ where: { id: rowId } });
411-
if (!fresh || fresh.updatedAt.getTime() === lastSyncedAt) {
420+
if (
421+
!fresh ||
422+
(lastSynced !== null &&
423+
fresh.concurrencyLimit === lastSynced.perKey &&
424+
fresh.totalConcurrencyLimit === lastSynced.total)
425+
) {
412426
return;
413427
}
414428
await settleBothEngineWrites(
@@ -423,7 +437,7 @@ function compensateEngineFromFreshRow(
423437
)
424438
: removeQueueTotalConcurrencyLimits(environment, fresh.name)
425439
);
426-
lastSyncedAt = fresh.updatedAt.getTime();
440+
lastSynced = { perKey: fresh.concurrencyLimit, total: fresh.totalConcurrencyLimit };
427441
}
428442
})(),
429443
(error) => ({ type: "other" as const, cause: error })

‎apps/webapp/test/concurrencyLimitsSystem.test.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { postgresTest } from "@internal/testcontainers";
22
import type { PrismaClient } from "@trigger.dev/database";
3-
import { describe, expect, vi } from "vitest";
3+
import { beforeEach, describe, expect, vi } from "vitest";
44
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
55
import { ConcurrencyLimitsSystem } from "~/v3/services/concurrencyLimitsSystem.server";
66

@@ -82,6 +82,16 @@ async function seedEnvAndLimit(
8282
}
8383

8484
describe("ConcurrencyLimitsSystem", () => {
85+
/** Call counts must start at zero per test and leaked one-off implementations
86+
* must not outlive the test that set them; mockReset also restores the default
87+
* implementations given to vi.fn above. */
88+
beforeEach(() => {
89+
perKeySyncMock.mockReset();
90+
perKeyRemoveMock.mockReset();
91+
totalSyncMock.mockReset();
92+
totalRemoveMock.mockReset();
93+
});
94+
8595
postgresTest(
8696
"override changes only the given bound and keeps the declared base",
8797
async ({ prisma }) => {
@@ -215,11 +225,13 @@ describe("ConcurrencyLimitsSystem", () => {
215225
}
216226

217227
/** The persist already happened; compensation re-syncs it so the engine
218-
* doesn't keep enforcing the old bound while the API reports the new one. */
228+
* doesn't keep enforcing the old bound while the API reports the new one.
229+
* Exactly two calls: the rejected primary sync, then the compensating
230+
* re-sync from the fresh row — without compensation there is only one. */
219231
const updated = await prisma.taskQueue.findFirstOrThrow({ where: { id: row.id } });
220232
expect(updated.totalConcurrencyLimit).toBe(50);
233+
expect(totalSyncMock).toHaveBeenCalledTimes(2);
221234
expect(totalSyncMock).toHaveBeenLastCalledWith(authEnv, "limit/openai", 50);
222-
expect(totalSyncMock.mock.calls.length).toBeGreaterThanOrEqual(2);
223235
}
224236
);
225237

@@ -248,8 +260,6 @@ describe("ConcurrencyLimitsSystem", () => {
248260
const final = await prisma.taskQueue.findFirstOrThrow({ where: { id: row.id } });
249261
expect(final.totalConcurrencyLimit).toBe(75);
250262
expect(engineTotal).toBe(75);
251-
252-
totalSyncMock.mockImplementation(async () => undefined);
253263
}
254264
);
255265

0 commit comments

Comments
 (0)