chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,327 @@
import { asyncJobs, db } from '@sim/db'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { eq, sql } from 'drizzle-orm'
import {
type EnqueueOptions,
JOB_STATUS,
type Job,
type JobMetadata,
type JobQueueBackend,
type JobStatus,
type JobType,
} from '@/lib/core/async-jobs/types'
const logger = createLogger('DatabaseJobQueue')
type AsyncJobRow = typeof asyncJobs.$inferSelect
type Runner = NonNullable<EnqueueOptions['runner']>
function rowToJob(row: AsyncJobRow): Job {
return {
id: row.id,
type: row.type as JobType,
payload: row.payload,
status: row.status as JobStatus,
createdAt: row.createdAt,
startedAt: row.startedAt ?? undefined,
completedAt: row.completedAt ?? undefined,
attempts: row.attempts,
maxAttempts: row.maxAttempts,
error: row.error ?? undefined,
output: row.output as unknown,
metadata: (row.metadata ?? {}) as JobMetadata,
}
}
const inlineAbortControllers = new Map<string, AbortController>()
/**
* Per-cancel-key abort controllers for the `batchEnqueueAndWait` direct-call
* path. Distinct from `inlineAbortControllers` (which keys by jobId) — this
* map keys by the domain `cancelKey` callers pass in, since the await-blocking
* path skips `async_jobs` entirely and has no jobId to cancel by.
*/
const inlineCancelKeyControllers = new Map<string, AbortController>()
interface Semaphore {
limit: number
available: number
waiters: Array<() => void>
}
const semaphores = new Map<string, Semaphore>()
async function acquireSlot(key: string, limit: number): Promise<void> {
let s = semaphores.get(key)
if (!s) {
s = { limit, available: limit, waiters: [] }
semaphores.set(key, s)
}
if (s.available > 0) {
s.available -= 1
return
}
await new Promise<void>((resolve) => s.waiters.push(resolve))
}
function releaseSlot(key: string): void {
const s = semaphores.get(key)
if (!s) return
const next = s.waiters.shift()
if (next) {
next()
return
}
s.available += 1
if (s.available === s.limit) {
semaphores.delete(key)
}
}
export class DatabaseJobQueue implements JobQueueBackend {
async enqueue<TPayload>(
type: JobType,
payload: TPayload,
options?: EnqueueOptions
): Promise<string> {
const jobId = options?.jobId ?? `run_${generateShortId(20)}`
const now = new Date()
await db
.insert(asyncJobs)
.values({
id: jobId,
type,
payload: payload as Record<string, unknown>,
status: JOB_STATUS.PENDING,
createdAt: now,
runAt:
options?.delayMs && options.delayMs > 0 ? new Date(now.getTime() + options.delayMs) : now,
attempts: 0,
maxAttempts: options?.maxAttempts ?? 3,
metadata: (options?.metadata ?? {}) as Record<string, unknown>,
updatedAt: now,
})
.onConflictDoNothing()
logger.debug('Enqueued job', { jobId, type })
if (options?.runner) {
this.runInline(
type,
jobId,
payload,
options.runner,
options.concurrencyKey,
options.concurrencyLimit
)
}
return jobId
}
async batchEnqueue<TPayload>(
type: JobType,
items: Array<{ payload: TPayload; options?: EnqueueOptions }>
): Promise<string[]> {
if (items.length === 0) return []
const now = new Date()
const rows = items.map(({ payload, options }) => ({
id: `run_${generateShortId(20)}`,
type,
payload: payload as Record<string, unknown>,
status: JOB_STATUS.PENDING,
createdAt: now,
attempts: 0,
maxAttempts: options?.maxAttempts ?? 3,
metadata: (options?.metadata ?? {}) as Record<string, unknown>,
updatedAt: now,
}))
await db.insert(asyncJobs).values(rows)
logger.debug('Batch-enqueued jobs', { count: rows.length, type })
for (let i = 0; i < items.length; i++) {
const { payload, options } = items[i]
if (options?.runner) {
this.runInline(
type,
rows[i].id,
payload,
options.runner,
options.concurrencyKey,
options.concurrencyLimit
)
}
}
return rows.map((r) => r.id)
}
/** Skips `async_jobs` entirely — ids are returned empty since callers can't
* look up rows that don't exist. Cancel goes through `cancelByKey`. */
async batchEnqueueAndWait<TPayload>(
type: JobType,
items: Array<{ payload: TPayload; options?: EnqueueOptions }>
): Promise<string[]> {
if (items.length === 0) return []
const tracked: Array<{ key: string; controller: AbortController }> = []
const runs = items.map((item) => {
const runner = item.options?.runner
if (!runner) return Promise.resolve()
const controller = new AbortController()
const cancelKey = item.options?.cancelKey
if (cancelKey) {
inlineCancelKeyControllers.set(cancelKey, controller)
tracked.push({ key: cancelKey, controller })
}
return runner(item.payload, controller.signal).catch((err) => {
logger.error(`[${type}] Inline run failed`, {
cancelKey,
error: toError(err).message,
})
})
})
try {
await Promise.all(runs)
} finally {
// Compare-and-delete guards against a re-enqueue under the same key
// racing with our cleanup.
for (const t of tracked) {
if (inlineCancelKeyControllers.get(t.key) === t.controller) {
inlineCancelKeyControllers.delete(t.key)
}
}
}
return items.map(() => '')
}
async getJob(jobId: string): Promise<Job | null> {
const [row] = await db.select().from(asyncJobs).where(eq(asyncJobs.id, jobId)).limit(1)
return row ? rowToJob(row) : null
}
async startJob(jobId: string): Promise<void> {
const now = new Date()
await db
.update(asyncJobs)
.set({
status: JOB_STATUS.PROCESSING,
startedAt: now,
attempts: sql`${asyncJobs.attempts} + 1`,
updatedAt: now,
})
.where(eq(asyncJobs.id, jobId))
logger.debug('Started job', { jobId })
}
async completeJob(jobId: string, output: unknown): Promise<void> {
const now = new Date()
await db
.update(asyncJobs)
.set({
status: JOB_STATUS.COMPLETED,
completedAt: now,
output: output as Record<string, unknown>,
updatedAt: now,
})
.where(eq(asyncJobs.id, jobId))
logger.debug('Completed job', { jobId })
}
async markJobFailed(jobId: string, error: string): Promise<void> {
const now = new Date()
await db
.update(asyncJobs)
.set({
status: JOB_STATUS.FAILED,
completedAt: now,
error,
updatedAt: now,
})
.where(eq(asyncJobs.id, jobId))
logger.debug('Marked job as failed', { jobId })
}
async cancelJob(jobId: string): Promise<void> {
// Abort any in-process inline execution first so the running workflow
// observes the signal and stops mid-flight. Then mark the row failed so
// any future poller skips it.
const controller = inlineAbortControllers.get(jobId)
let aborted = false
if (controller) {
controller.abort('Cancelled')
inlineAbortControllers.delete(jobId)
aborted = true
}
const now = new Date()
await db
.update(asyncJobs)
.set({
status: JOB_STATUS.FAILED,
completedAt: now,
error: 'Cancelled',
updatedAt: now,
})
.where(eq(asyncJobs.id, jobId))
logger.debug('Marked job as cancelled (DB queue)', { jobId, abortedInline: aborted })
}
cancelByKey(cancelKey: string): boolean {
const controller = inlineCancelKeyControllers.get(cancelKey)
if (!controller) return false
controller.abort('Cancelled')
inlineCancelKeyControllers.delete(cancelKey)
return true
}
/**
* Fire-and-forget IIFE that owns the lifecycle for an inline job: registers
* the abort controller (so `cancelJob` can interrupt mid-flight), acquires
* a concurrency slot if `concurrencyKey` is set, drives
* `startJob → runner → completeJob | markJobFailed`.
*/
private runInline<TPayload>(
type: JobType,
jobId: string,
payload: TPayload,
runner: Runner,
concurrencyKey?: string,
concurrencyLimit?: number
): void {
const abortController = new AbortController()
inlineAbortControllers.set(jobId, abortController)
void (async () => {
if (concurrencyKey && concurrencyLimit && concurrencyLimit > 0) {
await acquireSlot(concurrencyKey, concurrencyLimit)
}
try {
await this.startJob(jobId)
await runner(payload, abortController.signal)
await this.completeJob(jobId, null)
} catch (err) {
const message = toError(err).message
logger.error(`[${type}] Inline job ${jobId} failed`, { error: message })
try {
await this.markJobFailed(jobId, message)
} catch (markErr) {
logger.error(`[${type}] Failed to mark job ${jobId} as failed`, { markErr })
}
} finally {
inlineAbortControllers.delete(jobId)
if (concurrencyKey && concurrencyLimit && concurrencyLimit > 0) {
releaseSlot(concurrencyKey)
}
}
})()
}
}
@@ -0,0 +1,260 @@
import { createLogger } from '@sim/logger'
import { taskContext } from '@trigger.dev/core/v3'
import { runs, type TriggerOptions, tasks } from '@trigger.dev/sdk'
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import {
type EnqueueOptions,
JOB_STATUS,
type Job,
type JobMetadata,
type JobQueueBackend,
type JobStatus,
type JobType,
} from '@/lib/core/async-jobs/types'
const logger = createLogger('TriggerDevJobQueue')
/**
* Maps trigger.dev task IDs to our JobType
*/
const JOB_TYPE_TO_TASK_ID: Record<JobType, string> = {
'workflow-execution': 'workflow-execution',
'schedule-execution': 'schedule-execution',
'webhook-execution': 'webhook-execution',
'tiktok-webhook-ingress': 'tiktok-webhook-ingress',
'resume-execution': 'resume-execution',
'workflow-group-cell': 'workflow-group-cell',
'cleanup-logs': 'cleanup-logs',
'cleanup-soft-deletes': 'cleanup-soft-deletes',
'cleanup-tasks': 'cleanup-tasks',
'run-data-drain': 'run-data-drain',
}
/**
* Maps trigger.dev run status to our JobStatus
*/
function mapTriggerDevStatus(status: string): JobStatus {
switch (status) {
case 'QUEUED':
case 'WAITING_FOR_DEPLOY':
return JOB_STATUS.PENDING
case 'EXECUTING':
case 'RESCHEDULED':
case 'FROZEN':
return JOB_STATUS.PROCESSING
case 'COMPLETED':
return JOB_STATUS.COMPLETED
case 'CANCELED':
case 'FAILED':
case 'CRASHED':
case 'INTERRUPTED':
case 'SYSTEM_FAILURE':
case 'EXPIRED':
return JOB_STATUS.FAILED
default:
return JOB_STATUS.PENDING
}
}
/**
* Adapter that wraps the trigger.dev SDK to conform to JobQueueBackend interface.
*/
export class TriggerDevJobQueue implements JobQueueBackend {
async enqueue<TPayload>(
type: JobType,
payload: TPayload,
options?: EnqueueOptions
): Promise<string> {
const taskId = JOB_TYPE_TO_TASK_ID[type]
if (!taskId) {
throw new Error(`Unknown job type: ${type}`)
}
const enrichedPayload =
options?.metadata && typeof payload === 'object' && payload !== null
? { ...payload, ...options.metadata }
: payload
const tags = buildTags(options)
const triggerOptions: TriggerOptions = {}
if (tags.length > 0) triggerOptions.tags = tags
if (options?.concurrencyKey) triggerOptions.concurrencyKey = options.concurrencyKey
if (options?.jobId) {
triggerOptions.idempotencyKey = options.jobId
triggerOptions.idempotencyKeyTTL = '14d'
}
if (options?.delayMs && options.delayMs > 0) {
triggerOptions.delay = new Date(Date.now() + options.delayMs)
}
triggerOptions.region = await resolveTriggerRegion()
const handle = await tasks.trigger(taskId, enrichedPayload, triggerOptions)
logger.debug('Enqueued job via trigger.dev', { jobId: handle.id, type, taskId, tags })
return handle.id
}
async batchEnqueue<TPayload>(
type: JobType,
items: Array<{ payload: TPayload; options?: EnqueueOptions }>
): Promise<string[]> {
if (items.length === 0) return []
// tasks.batchTrigger returns only a batchId, not per-item run IDs, so we
// can't use it when callers need to track individual runs (e.g. table cell
// tasks need per-row jobIds for cancellation). Sequential `tasks.trigger`
// gives us per-item IDs and naturally preserves input order in the queue.
const ids: string[] = []
for (const { payload, options } of items) {
const id = await this.enqueue(type, payload, options)
ids.push(id)
}
return ids
}
async batchEnqueueAndWait<TPayload>(
type: JobType,
items: Array<{ payload: TPayload; options?: EnqueueOptions }>
): Promise<string[]> {
if (items.length === 0) return []
// The SDK's checkpoint-and-resume requires task runtime context. The only
// caller (`dispatcherStep` invoked by `tableRunDispatcherTask.run`) is
// always inside a task; check defensively so misuse fails at the boundary
// instead of as a confusing SDK internal error.
if (!taskContext.isInsideTask) {
throw new Error(
'batchEnqueueAndWait requires trigger.dev task runtime context — call from within a registered task'
)
}
const taskId = JOB_TYPE_TO_TASK_ID[type]
if (!taskId) throw new Error(`Unknown job type: ${type}`)
const region = await resolveTriggerRegion()
const batchItems = items.map(({ payload, options }) => {
const enrichedPayload =
options?.metadata && typeof payload === 'object' && payload !== null
? { ...payload, ...options.metadata }
: payload
const tags = buildTags(options)
const batchItem: {
payload: unknown
options?: { concurrencyKey?: string; tags?: string[]; region?: string }
} = { payload: enrichedPayload }
const batchOpts: { concurrencyKey?: string; tags?: string[]; region?: string } = { region }
if (options?.concurrencyKey) batchOpts.concurrencyKey = options.concurrencyKey
if (tags.length > 0) batchOpts.tags = tags
batchItem.options = batchOpts
return batchItem
})
const result = await tasks.batchTriggerAndWait(taskId, batchItems)
logger.debug('batchTriggerAndWait completed', {
type,
taskId,
runCount: result.runs.length,
})
return result.runs.map((r) => r.id)
}
async getJob(jobId: string): Promise<Job | null> {
try {
const run = await runs.retrieve(jobId)
const payload = run.payload as Record<string, unknown>
const metadata: JobMetadata = {
workflowId: payload?.workflowId as string | undefined,
userId: payload?.userId as string | undefined,
correlation:
payload?.correlation && typeof payload.correlation === 'object'
? (payload.correlation as JobMetadata['correlation'])
: undefined,
}
return {
id: jobId,
type: run.taskIdentifier as JobType,
payload: run.payload,
status: mapTriggerDevStatus(run.status),
createdAt: run.createdAt ? new Date(run.createdAt) : new Date(),
startedAt: run.startedAt ? new Date(run.startedAt) : undefined,
completedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
attempts: run.attemptCount ?? 1,
maxAttempts: 3,
error: run.error?.message,
output: run.output as unknown,
metadata,
}
} catch (error) {
const isNotFound =
(error instanceof Error && error.message.toLowerCase().includes('not found')) ||
(error && typeof error === 'object' && 'status' in error && error.status === 404)
if (isNotFound) {
logger.debug('Job not found in trigger.dev', { jobId })
return null
}
logger.error('Failed to get job from trigger.dev', { jobId, error })
throw error
}
}
async startJob(_jobId: string): Promise<void> {}
async completeJob(_jobId: string, _output: unknown): Promise<void> {}
async markJobFailed(_jobId: string, _error: string): Promise<void> {}
async cancelJob(jobId: string): Promise<void> {
try {
await runs.cancel(jobId)
logger.debug('Cancelled trigger.dev run', { jobId })
} catch (error) {
const isNotFound =
(error instanceof Error && error.message.toLowerCase().includes('not found')) ||
(error && typeof error === 'object' && 'status' in error && error.status === 404)
if (isNotFound) {
logger.debug('Cancel target not found in trigger.dev (already finished?)', { jobId })
return
}
logger.error('Failed to cancel trigger.dev run', { jobId, error })
throw error
}
}
cancelByKey(_cancelKey: string): boolean {
// No in-process AbortControllers to abort — trigger.dev runs are cancelled
// by jobId or via tag sweep (see `cancelCellRunsByTags`). Callers that
// need both surfaces should fan out themselves.
return false
}
}
/**
* Derives trigger.dev tags from job type, metadata, and explicit tags.
* Tags follow the `namespace:value` convention for consistent filtering.
* Max 10 tags per run, each max 128 chars.
*/
function buildTags(options?: EnqueueOptions): string[] {
const tags: string[] = []
const meta = options?.metadata
if (meta?.workspaceId) tags.push(`workspaceId:${meta.workspaceId}`)
if (meta?.workflowId) tags.push(`workflowId:${meta.workflowId}`)
if (meta?.userId) tags.push(`userId:${meta.userId}`)
if (meta?.correlation) {
const c = meta.correlation
tags.push(`source:${c.source}`)
if (c.webhookId) tags.push(`webhookId:${c.webhookId}`)
if (c.scheduleId) tags.push(`scheduleId:${c.scheduleId}`)
if (c.provider) tags.push(`provider:${c.provider}`)
}
if (options?.tags) {
for (const tag of options.tags) {
if (!tags.includes(tag)) tags.push(tag)
}
}
return tags.slice(0, 10)
}