Skip to content

Commit 0de05d4

Browse files
committed
chore(release): merge staging into the v0.9.43 release
2 parents d2db2dd + b8b7201 commit 0de05d4

16 files changed

Lines changed: 317 additions & 88 deletions

File tree

‎apps/sim/lib/admin/dashboard-usage.ts‎

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { db } from '@sim/db'
22
import { member, permissions, usageLog, workspace } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { getPostgresCancellationReason } from '@sim/utils/errors'
54
import { and, countDistinct, eq, inArray, notInArray, type SQL, sql } from 'drizzle-orm'
65
import { unionAll } from 'drizzle-orm/pg-core'
7-
import { readLedgerBounded } from '@/lib/billing/core/ledger-read'
6+
import {
7+
isLedgerReadOverBudget,
8+
readLedgerBounded,
9+
WORKFLOW_RUN_COUNT_BUDGET_MS,
10+
} from '@/lib/billing/core/ledger-read'
811
import type { ResolvedUsagePeriod } from '@/lib/billing/core/reporting-period'
912
import { buildUsageAnalyticsScope } from '@/lib/billing/core/usage-analytics'
1013
import type { BillingEntity } from '@/lib/billing/core/usage-log'
11-
import { DatabaseReadDeadlineError } from '@/lib/db/read-retry'
1214

1315
/**
1416
* Ledger reads behind the admin dashboard: each billing entity's spend and workflow runs
@@ -26,7 +28,7 @@ import { DatabaseReadDeadlineError } from '@/lib/db/read-retry'
2628
* Widening both entity indexes with it would grow the hot billing sums' working set to
2729
* speed up a staff-only page. So only one organization's detail counts runs, in a single
2830
* scan that yields the total and every actor's share, under
29-
* {@link WORKFLOW_RUNS_BUDGET_MS}; past it the count is unknown (`null`) and the page
31+
* {@link WORKFLOW_RUN_COUNT_BUDGET_MS}; past it the count is unknown (`null`) and the page
3032
* still loads.
3133
*
3234
* They stay on the primary, where the hot billing sums keep those index pages cached. On
@@ -54,12 +56,6 @@ interface DashboardWorkflowRuns {
5456
formerActors: number
5557
}
5658

57-
/**
58-
* How long one detail page may spend counting workflow runs. Well inside the admin
59-
* proxy's request timeout, so a payer too large to count still gets its page.
60-
*/
61-
const WORKFLOW_RUNS_BUDGET_MS = 5_000
62-
6359
const WORKFLOW_SOURCE = sql`${usageLog.source} = 'workflow'`
6460

6561
export function organizationWindow(
@@ -164,7 +160,7 @@ function isFormerActor(organizationId: string): SQL {
164160
/**
165161
* An organization's workflow runs over its period, in total, per acting user and across its
166162
* former actors, from one scan. `null` when the count does not finish within
167-
* {@link WORKFLOW_RUNS_BUDGET_MS}.
163+
* {@link WORKFLOW_RUN_COUNT_BUDGET_MS}.
168164
*/
169165
export async function readDashboardOrganizationWorkflowRuns(
170166
organizationId: string,
@@ -193,16 +189,13 @@ export async function readDashboardOrganizationWorkflowRuns(
193189
.from(workflowRows)
194190
.groupBy(sql`grouping sets ((${workflowRows.userId}), (${workflowRows.formerActor}), ())`)
195191
},
196-
WORKFLOW_RUNS_BUDGET_MS
192+
WORKFLOW_RUN_COUNT_BUDGET_MS
197193
)
198194
} catch (error) {
199-
const overBudget =
200-
error instanceof DatabaseReadDeadlineError ||
201-
getPostgresCancellationReason(error) === 'statement_timeout'
202-
if (!overBudget) throw error
195+
if (!isLedgerReadOverBudget(error)) throw error
203196
logger.warn('Workflow run count exceeded its budget', {
204197
organizationId,
205-
budgetMs: WORKFLOW_RUNS_BUDGET_MS,
198+
budgetMs: WORKFLOW_RUN_COUNT_BUDGET_MS,
206199
})
207200
return null
208201
}

‎apps/sim/lib/billing/core/ledger-read.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getPostgresCancellationReason } from '@sim/utils/errors'
12
import { sql } from 'drizzle-orm'
23
import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants'
34
import { DatabaseReadDeadlineError, withDatabaseReadRetry } from '@/lib/db/read-retry'
@@ -36,3 +37,17 @@ export function readLedgerBounded<T>(
3637
{ label: 'usage ledger', maxAttempts: 2, maxElapsedMs: budgetMs }
3738
)
3839
}
40+
41+
/**
42+
* How long a staff-only workflow run count may take before it is reported as unknown. Well inside
43+
* the admin proxy's request timeout, so a payer too large to count still gets its page.
44+
*/
45+
export const WORKFLOW_RUN_COUNT_BUDGET_MS = 5_000
46+
47+
/** Whether a {@link readLedgerBounded} read failed because it ran out of its budget. */
48+
export function isLedgerReadOverBudget(error: unknown): boolean {
49+
return (
50+
error instanceof DatabaseReadDeadlineError ||
51+
getPostgresCancellationReason(error) === 'statement_timeout'
52+
)
53+
}

‎apps/sim/lib/billing/core/usage-log.integration.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
CumulativeUsagePeriodClosedError,
3333
getBillingPeriodUsageCost,
3434
getBillingPeriodUsageCostByUser,
35+
getBillingPeriodWorkflowRunCountWithinBudget,
3536
getStampedPeriodRangeUsageCostByUser,
3637
type RecordCumulativeUsageParams,
3738
recordCumulativeUsage,
@@ -375,6 +376,38 @@ describe('Cumulative billing with PostgreSQL', () => {
375376
)
376377
})
377378

379+
it('reports a workflow run count as unknown once it outlasts the preview budget', async () => {
380+
const entity = { type: 'organization' as const, id: 'org-runs' }
381+
const period = {
382+
start: new Date('2026-08-01T00:00:00.000Z'),
383+
end: new Date('2026-09-01T00:00:00.000Z'),
384+
source: 'reporting' as const,
385+
}
386+
await connection`
387+
insert into usage_log (id, user_id, category, source, description, cost,
388+
billing_entity_type, billing_entity_id, execution_id, created_at)
389+
values ('r1', 'u1', 'model', 'workflow', 'run', 1, 'organization', 'org-runs', 'e1', '2026-08-02'),
390+
('r2', 'u1', 'tool', 'workflow', 'run', 1, 'organization', 'org-runs', 'e1', '2026-08-02'),
391+
('r3', 'u1', 'model', 'workflow', 'run', 1, 'organization', 'org-runs', 'e2', '2026-08-03')
392+
`
393+
expect(await getBillingPeriodWorkflowRunCountWithinBudget(entity, period)).toBe(2)
394+
395+
const release = deferred()
396+
const locked = deferred()
397+
const holder = connection.begin(async (tx) => {
398+
await tx.unsafe('LOCK TABLE usage_log IN ACCESS EXCLUSIVE MODE')
399+
locked.resolve()
400+
await release.promise
401+
})
402+
try {
403+
await locked.promise
404+
expect(await getBillingPeriodWorkflowRunCountWithinBudget(entity, period)).toBeNull()
405+
} finally {
406+
release.resolve()
407+
await holder
408+
}
409+
}, 20_000)
410+
378411
describe('a request that outlives its billing period', () => {
379412
// Past periods: the old period's row is written under the subscription lock only once
380413
// that period has ended.

‎apps/sim/lib/billing/core/usage-log.ts‎

Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import {
1818
timestampKey,
1919
} from '@/lib/api/list-query'
2020
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
21-
import { readLedgerBounded } from '@/lib/billing/core/ledger-read'
21+
import {
22+
isLedgerReadOverBudget,
23+
readLedgerBounded,
24+
WORKFLOW_RUN_COUNT_BUDGET_MS,
25+
} from '@/lib/billing/core/ledger-read'
2226
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
2327
import {
2428
resolveSubscriptionUsagePeriod,
@@ -269,44 +273,74 @@ export async function getBillingPeriodUsageCost(
269273
export async function getBillingPeriodWorkflowRunCount(
270274
billingEntity: BillingEntity,
271275
billingPeriod: UsageQueryPeriod,
272-
executor: DbClient = db
276+
executor: DbClient = db,
277+
budgetMs?: number
273278
): Promise<number> {
274-
const [row] = await readLedgerBounded(executor, (tx) =>
275-
tx
276-
.select({
277-
/**
278-
* The exclusion goes through `notInArray`, not `<> ALL(${array})`. Interpolating
279-
* a JavaScript array into a `sql` template emits parenthesized scalar binds —
280-
* `ALL(($1))` — which Postgres rejects outright with "op ANY/ALL (array)
281-
* requires array on right side". Unit tests cannot catch it, because `@sim/db`
282-
* is mocked and no statement is ever rendered.
283-
*/
284-
workflowRuns:
285-
sql<number>`COUNT(DISTINCT ${usageLog.executionId}) FILTER (WHERE ${usageLog.source} = 'workflow' AND ${notInArray(usageLog.category, [...UNBILLED_USAGE_CATEGORIES])})`.mapWith(
286-
Number
287-
),
288-
})
289-
.from(usageLog)
290-
.where(
291-
and(
292-
eq(usageLog.billingEntityType, billingEntity.type),
293-
eq(usageLog.billingEntityId, billingEntity.id),
294-
...(billingPeriod.source === 'reporting'
295-
? [
296-
gte(usageLog.createdAt, billingPeriod.start),
297-
lt(usageLog.createdAt, billingPeriod.end),
298-
]
299-
: [
300-
eq(usageLog.billingPeriodStart, billingPeriod.start),
301-
eq(usageLog.billingPeriodEnd, billingPeriod.end),
302-
])
303-
)
304-
)
279+
const [row] = await readLedgerBounded(
280+
executor,
281+
(tx) =>
282+
tx
283+
.select({
284+
/**
285+
* The exclusion goes through `notInArray`, not `<> ALL(${array})`. Interpolating
286+
* a JavaScript array into a `sql` template emits parenthesized scalar binds —
287+
* `ALL(($1))` — which Postgres rejects outright with "op ANY/ALL (array)
288+
* requires array on right side". Unit tests cannot catch it, because `@sim/db`
289+
* is mocked and no statement is ever rendered.
290+
*/
291+
workflowRuns:
292+
sql<number>`COUNT(DISTINCT ${usageLog.executionId}) FILTER (WHERE ${usageLog.source} = 'workflow' AND ${notInArray(usageLog.category, [...UNBILLED_USAGE_CATEGORIES])})`.mapWith(
293+
Number
294+
),
295+
})
296+
.from(usageLog)
297+
.where(
298+
and(
299+
eq(usageLog.billingEntityType, billingEntity.type),
300+
eq(usageLog.billingEntityId, billingEntity.id),
301+
...(billingPeriod.source === 'reporting'
302+
? [
303+
gte(usageLog.createdAt, billingPeriod.start),
304+
lt(usageLog.createdAt, billingPeriod.end),
305+
]
306+
: [
307+
eq(usageLog.billingPeriodStart, billingPeriod.start),
308+
eq(usageLog.billingPeriodEnd, billingPeriod.end),
309+
])
310+
)
311+
),
312+
budgetMs
305313
)
306314

307315
return row?.workflowRuns ?? 0
308316
}
309317

318+
/**
319+
* {@link getBillingPeriodWorkflowRunCount} for a staff-only preview that can do without it:
320+
* `null` when the count does not finish within {@link WORKFLOW_RUN_COUNT_BUDGET_MS}, so a payer
321+
* too large to count still gets its preview inside the admin proxy's request timeout.
322+
*/
323+
export async function getBillingPeriodWorkflowRunCountWithinBudget(
324+
billingEntity: BillingEntity,
325+
billingPeriod: UsageQueryPeriod
326+
): Promise<number | null> {
327+
try {
328+
return await getBillingPeriodWorkflowRunCount(
329+
billingEntity,
330+
billingPeriod,
331+
db,
332+
WORKFLOW_RUN_COUNT_BUDGET_MS
333+
)
334+
} catch (error) {
335+
if (!isLedgerReadOverBudget(error)) throw error
336+
logger.warn('Billing period workflow run count exceeded its budget', {
337+
billingEntityType: billingEntity.type,
338+
budgetMs: WORKFLOW_RUN_COUNT_BUDGET_MS,
339+
})
340+
return null
341+
}
342+
}
343+
310344
/**
311345
* Period total plus the portion attributable to `source`, in a single scan.
312346
*

‎apps/sim/lib/billing/enterprise-provisioning.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { getBillingConcurrencyLimit } from '@/lib/billing/concurrency-limits'
3737
import { resolveEnterpriseReportingPeriod } from '@/lib/billing/core/reporting-period'
3838
import {
3939
getBillingPeriodUsageCost,
40-
getBillingPeriodWorkflowRunCount,
40+
getBillingPeriodWorkflowRunCountWithinBudget,
4141
} from '@/lib/billing/core/usage-log'
4242
import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion'
4343
import {
@@ -462,7 +462,8 @@ export interface EnterpriseIssuancePreflight {
462462
limitDollars: number
463463
usedCredits: number
464464
limitCredits: number
465-
workflowRuns: number
465+
/** `null` when the count did not finish within its budget. */
466+
workflowRuns: number | null
466467
}
467468
invoiceAmountUsd: number
468469
configuredUsageLimitDollars: number
@@ -865,7 +866,7 @@ export async function getEnterpriseIssuancePreflight({
865866
{ type: 'organization', id: membership.organizationId },
866867
reportingPeriod
867868
),
868-
getBillingPeriodWorkflowRunCount(
869+
getBillingPeriodWorkflowRunCountWithinBudget(
869870
{ type: 'organization', id: membership.organizationId },
870871
reportingPeriod
871872
),

‎apps/sim/lib/core/utils/urls.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,9 @@ export function getBaseUrl(): string {
7575
* Whether this process is a Trigger.dev worker rather than the app container.
7676
*
7777
* `TRIGGER_SECRET_KEY` and `TRIGGER_DEV_ENABLED` are both present on the app too
78-
* — it needs them to dispatch — so neither discriminates. `trigger.config.ts`
79-
* syncs exactly one worker-only marker, `DB_APP_NAME='sim-trigger'`, which is
80-
* what this reads.
78+
* — it needs them to dispatch — so neither discriminates. Every Trigger.dev
79+
* deploy sets `DB_APP_NAME='sim-trigger'` (`TRIGGER_CONSTANT_VARS` in
80+
* `@sim/runtime-secrets/trigger-env`), which the app never has, so this reads it.
8181
*/
8282
function isTriggerWorkerRuntime(): boolean {
8383
return getEnv('DB_APP_NAME') === 'sim-trigger'

‎apps/sim/lib/workspace-files/search/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,6 @@ The design uses PostgreSQL's documented [TOAST behavior](https://www.postgresql.
7171

7272
Migration `0426_file_search_owner_scope.sql` adds canonical workspace/Project ownership to the existing build, revision, and chunk pipeline. Legacy workspace inserts are translated by triggers, and the existing workspace trigram index remains usable. Project rows use a separate owner-scoped trigram index on the same chunk table. Organizations and personal users are not enabled search owners.
7373

74-
The new `file_search_dispatch_queue` replaces the workspace-keyed scheduler because older workers cannot decode a Project without a workspace. During expansion, workspace mutations enqueue both queues, while the new dispatcher reads the owner queue. Both deployments share the dispatcher lock, revision claim token, build lease, and publication fence. Drain old app dispatchers and Trigger workers/retries before enabling Project dispatch. Retire the legacy queue and its dual enqueue in a later contract deploy; they are not a second indexing pipeline. The `file-search-owners-v3` cursor performs bounded owner/file keyset backfill and hourly reconciliation without rewriting extracted text in the migration. Claims take owners woken since their last dispatch first (`wokenSinceDispatch` in `dispatcher.ts`); backfill wake-ups enter as if just dispatched and never touch an owner already queued, so a page of them cannot delay a file write by several passes.
74+
The new `file_search_dispatch_queue` replaces the workspace-keyed scheduler because older workers cannot decode a Project without a workspace. During expansion, workspace mutations enqueue both queues, while the new dispatcher reads the owner queue. Both deployments share the dispatcher lock, revision claim token, build lease, and publication fence. Drain old app dispatchers and Trigger workers/retries before enabling Project dispatch. Retire the legacy queue and its dual enqueue in a later contract deploy; they are not a second indexing pipeline. The `file-search-owners-v3` cursor performs bounded owner/file keyset backfill and hourly reconciliation without rewriting extracted text in the migration. Each pass selects owners woken since their last dispatch that still have a free slot first (`wokenSinceDispatch` in `dispatcher.ts`), then the rest by dispatch age, and fills its capacity owner by owner in that order. Backfill wake-ups enter as if just dispatched and never touch an owner already queued, so a page of them rotates in behind owners woken for work.
7575

7676
Generated document indexing never executes source. A cached artifact must match its owner and current input manifest. Each published build stores at most 500 input identities. Queries exclude a build when any input is changed, archived, deleted, or outside the owner, and report it as pending coverage. Input mutations wake their owner queue; the dispatcher retires stale manifests in bounded batches before rebuilding. Expired build cleanup also removes its dependency records. A cache miss indexes the stored generation source as text until an authorized rendering path creates a current artifact.

‎apps/sim/lib/workspace-files/search/dispatcher.integration.ts‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,10 +409,14 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
409409
})
410410
}
411411

412-
/** Fills global capacity with claims of an unqueued owner, leaving `slots` free. */
412+
/**
413+
* Fills global capacity with claims of an owner that is neither queued nor walked by the
414+
* backfill (its files are deleted), leaving `slots` free.
415+
*/
413416
async function leaveSlots(slots: number) {
414417
await seedQueue('workspace-active', 0, FILE_SEARCH_INDEX_MAX_OUTSTANDING - slots)
415418
await connection`DELETE FROM file_search_dispatch_queue WHERE entity_id = 'workspace-active'`
419+
await connection`UPDATE workspace_files SET deleted_at = now() WHERE workspace_id = 'workspace-active'`
416420
}
417421

418422
const wakeUps = FILE_SEARCH_INDEX_DISPATCH_WORKSPACES * 2 + 50
@@ -469,6 +473,40 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
469473
expect(result.payloads.map((payload) => payload.fileId)).toEqual(['waiting-workspace-000001'])
470474
})
471475

476+
it('fills the free capacity in selection order, so a woken owner is not outbid by an older backlog', async () => {
477+
await leaveSlots(2)
478+
// Served ten minutes ago and not woken since, with older pending files than the fresh write.
479+
await seedQueue('backlog-workspace', 3)
480+
await connection`UPDATE file_search_dispatch_queue SET last_dispatched_at = now() - interval '10 minutes',
481+
updated_at = now() - interval '10 minutes' WHERE entity_id = 'backlog-workspace'`
482+
await writeFile('fresh-workspace')
483+
484+
const result = await prepareWorkspaceFileSearchDispatch()
485+
expect(result.payloads.map((payload) => payload.fileId).sort()).toEqual([
486+
'backlog-workspace-000001',
487+
'fresh-workspace-written',
488+
])
489+
})
490+
491+
it('keeps a reaped claim woken when the backfill queues its owner in the same pass', async () => {
492+
await leaveSlots(1)
493+
// Its only claim was abandoned before its run was enqueued; nothing has queued its owner yet.
494+
await seedQueue('reaped-workspace', 0, 1)
495+
await connection`DELETE FROM file_search_dispatch_queue WHERE entity_id = 'reaped-workspace'`
496+
await connection`UPDATE workspace_file_search_revision
497+
SET handoff_expires_at = clock_timestamp() - interval '1 millisecond'
498+
WHERE file_id = 'reaped-workspace-000001'`
499+
// Served an hour ago and not woken since: it wins the single slot only if the reap is lost.
500+
await seedQueue('waiting-workspace', 1)
501+
await connection`UPDATE file_search_dispatch_queue SET last_dispatched_at = now() - interval '1 hour',
502+
updated_at = now() - interval '1 hour' WHERE entity_id = 'waiting-workspace'`
503+
await connection`UPDATE workspace_file_search_backfill SET completed_at = NULL`
504+
505+
const result = await prepareWorkspaceFileSearchDispatch()
506+
expect(result.reapedClaims).toBe(1)
507+
expect(result.payloads.map((payload) => payload.fileId)).toEqual(['reaped-workspace-000001'])
508+
})
509+
472510
it('serves a fresh write before work the backfill found, then the backfill work', async () => {
473511
await leaveSlots(0)
474512
await connection`UPDATE workspace_file_search_backfill SET completed_at = NULL`
@@ -494,7 +532,7 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
494532

495533
it('leaves an owner queued by a file write at the front when a backfill page passes it', async () => {
496534
await leaveSlots(1)
497-
// Queued first and served an hour ago: it wins the single slot only if the write is demoted.
535+
// Woken and served an hour ago: it wins the single slot only if the write is demoted.
498536
await seedQueue('earlier-served', 1)
499537
await connection`UPDATE file_search_dispatch_queue
500538
SET last_dispatched_at = now() - interval '1 hour' WHERE entity_id = 'earlier-served'`

0 commit comments

Comments
 (0)