d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
187 lines
6.7 KiB
TypeScript
187 lines
6.7 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { dataDrainRuns, dataDrains } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { and, eq, isNull, lt, or } from 'drizzle-orm'
|
|
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
|
|
import { getJobQueue } from '@/lib/core/async-jobs'
|
|
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
|
|
|
const logger = createLogger('DataDrainsDispatcher')
|
|
|
|
const HOUR_MS = 60 * 60 * 1000
|
|
const DAY_MS = 24 * HOUR_MS
|
|
|
|
/**
|
|
* Cron fires hourly. Without a buffer, a drain that finishes a few minutes
|
|
* after the tick (lastRunAt = 10:05) won't satisfy `lastRunAt < now - cadence`
|
|
* at the next tick (10:05 < 10:00 is false), so an "hourly" drain effectively
|
|
* runs every two hours. Subtracting a small buffer from the cadence absorbs
|
|
* normal run duration plus cron jitter without allowing back-to-back runs
|
|
* within the same tick.
|
|
*/
|
|
const CADENCE_BUFFER_MS = 5 * 60 * 1000
|
|
|
|
/**
|
|
* Maximum wall-clock duration any single drain run is allowed before its
|
|
* `data_drain_runs` row is considered orphaned. Runs that exceed this are
|
|
* almost certainly the result of a Trigger.dev worker crash mid-run — there
|
|
* is no live process still updating them.
|
|
*/
|
|
const ORPHAN_THRESHOLD_MS = 60 * 60 * 1000
|
|
|
|
/**
|
|
* Marks `running` rows older than the orphan threshold as `failed`. Without
|
|
* this, a worker crash leaves run history permanently misleading and (worse)
|
|
* the drain row's `lastRunAt` reflects a successful claim that never finished
|
|
* — but the drain `cursor` never advanced, so re-running is safe.
|
|
*/
|
|
export async function reapOrphanedRuns(now: Date = new Date()): Promise<{ reaped: number }> {
|
|
const cutoff = new Date(now.getTime() - ORPHAN_THRESHOLD_MS)
|
|
const reaped = await db
|
|
.update(dataDrainRuns)
|
|
.set({
|
|
status: 'failed',
|
|
finishedAt: now,
|
|
error: `Orphaned run reaped after exceeding ${ORPHAN_THRESHOLD_MS / 60_000}m without completion`,
|
|
})
|
|
.where(and(eq(dataDrainRuns.status, 'running'), lt(dataDrainRuns.startedAt, cutoff)))
|
|
.returning({ id: dataDrainRuns.id })
|
|
if (reaped.length > 0) {
|
|
logger.warn('Reaped orphaned data drain runs', { count: reaped.length })
|
|
}
|
|
return { reaped: reaped.length }
|
|
}
|
|
|
|
/**
|
|
* Selects every enabled drain whose schedule is due (or has never run) and
|
|
* fans out one `run-data-drain` job per drain. Each drain is atomically
|
|
* claimed via a conditional UPDATE before being enqueued — two concurrent
|
|
* dispatcher invocations cannot both win the same row, and a manual run that
|
|
* lands between the SELECT and the UPDATE will lose the race cleanly. Drains
|
|
* belonging to orgs that have lapsed off the enterprise plan are skipped.
|
|
*/
|
|
export async function dispatchDueDrains(now: Date = new Date()): Promise<{
|
|
candidates: number
|
|
dispatched: number
|
|
skipped: number
|
|
reaped: number
|
|
}> {
|
|
const { reaped } = await reapOrphanedRuns(now)
|
|
|
|
const hourlyCutoff = new Date(now.getTime() - HOUR_MS + CADENCE_BUFFER_MS)
|
|
const dailyCutoff = new Date(now.getTime() - DAY_MS + CADENCE_BUFFER_MS)
|
|
|
|
const duePredicate = and(
|
|
eq(dataDrains.enabled, true),
|
|
or(
|
|
isNull(dataDrains.lastRunAt),
|
|
and(eq(dataDrains.scheduleCadence, 'hourly'), lt(dataDrains.lastRunAt, hourlyCutoff)),
|
|
and(eq(dataDrains.scheduleCadence, 'daily'), lt(dataDrains.lastRunAt, dailyCutoff))
|
|
)
|
|
)
|
|
|
|
const candidates = await db
|
|
.select({
|
|
id: dataDrains.id,
|
|
organizationId: dataDrains.organizationId,
|
|
lastRunAt: dataDrains.lastRunAt,
|
|
})
|
|
.from(dataDrains)
|
|
.where(duePredicate)
|
|
|
|
if (candidates.length === 0) {
|
|
return { candidates: 0, dispatched: 0, skipped: 0, reaped }
|
|
}
|
|
|
|
// Self-hosted deployments have no subscription infra; `DATA_DRAINS_ENABLED`
|
|
// is the global on/off there. Cache per-org so a multi-drain org pays one
|
|
// billing lookup.
|
|
const enterpriseCache = new Map<string, boolean>()
|
|
const isEnterprise = async (orgId: string): Promise<boolean> => {
|
|
if (!isBillingEnabled) return true
|
|
const cached = enterpriseCache.get(orgId)
|
|
if (cached !== undefined) return cached
|
|
const result = await isOrganizationOnEnterprisePlan(orgId)
|
|
enterpriseCache.set(orgId, result)
|
|
return result
|
|
}
|
|
|
|
const queue = await getJobQueue()
|
|
let dispatched = 0
|
|
let skipped = 0
|
|
|
|
for (const candidate of candidates) {
|
|
let enterprise: boolean
|
|
try {
|
|
enterprise = await isEnterprise(candidate.organizationId)
|
|
} catch (error) {
|
|
// A billing-API failure for one org must not abort the whole batch —
|
|
// skip this drain and let the next cron tick retry it.
|
|
logger.warn('Enterprise check failed; skipping drain', {
|
|
drainId: candidate.id,
|
|
organizationId: candidate.organizationId,
|
|
error,
|
|
})
|
|
skipped++
|
|
continue
|
|
}
|
|
if (!enterprise) {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
// Conditional claim — re-asserts the due predicate to lose to any other
|
|
// dispatcher or manual-run path that's already moved this drain forward.
|
|
const claimed = await db
|
|
.update(dataDrains)
|
|
.set({ lastRunAt: now, updatedAt: now })
|
|
.where(and(eq(dataDrains.id, candidate.id), duePredicate))
|
|
.returning({ id: dataDrains.id })
|
|
|
|
if (claimed.length === 0) continue
|
|
|
|
try {
|
|
// concurrencyKey serializes runs of the same drain on the job queue, so
|
|
// a manual run-now racing a cron claim can never execute in parallel.
|
|
await queue.enqueue(
|
|
'run-data-drain',
|
|
{ drainId: candidate.id, trigger: 'cron' },
|
|
{ concurrencyKey: `data-drain:${candidate.id}` }
|
|
)
|
|
dispatched++
|
|
} catch (error) {
|
|
// Roll back the claim so a transient queue outage doesn't delay this
|
|
// drain by a full cadence. Scoped to our own claim timestamp so it
|
|
// can't trample a concurrent advance. The rollback itself is guarded
|
|
// so a DB error here doesn't abort the rest of the batch.
|
|
try {
|
|
await db
|
|
.update(dataDrains)
|
|
.set({ lastRunAt: candidate.lastRunAt, updatedAt: now })
|
|
.where(and(eq(dataDrains.id, candidate.id), eq(dataDrains.lastRunAt, now)))
|
|
} catch (rollbackError) {
|
|
logger.error('Failed to roll back data-drain claim after enqueue failure', {
|
|
drainId: candidate.id,
|
|
enqueueError: toError(error).message,
|
|
rollbackError: toError(rollbackError).message,
|
|
})
|
|
continue
|
|
}
|
|
logger.error('Failed to enqueue data-drain job; rolled back claim', {
|
|
drainId: candidate.id,
|
|
error,
|
|
})
|
|
}
|
|
}
|
|
|
|
logger.info('Data drain dispatch complete', {
|
|
candidates: candidates.length,
|
|
dispatched,
|
|
skipped,
|
|
reaped,
|
|
})
|
|
|
|
return { candidates: candidates.length, dispatched, skipped, reaped }
|
|
}
|