Files
simstudioai--sim/apps/sim/lib/table/cascade-lock.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

62 lines
2.4 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { acquireLock, extendLock, releaseLock } from '@/lib/core/config/redis'
const logger = createLogger('TableCascadeLock')
/** Lock TTL. Crashed pods release within this many seconds. */
const LOCK_TTL_SECONDS = 30
/** Heartbeat cadence. ~3x within TTL — tolerates two missed beats. */
const HEARTBEAT_INTERVAL_MS = 10_000
/** Single source of truth for the cascade-lock key shape. The lock arbitrates
* ownership of a row's full workflow-group cascade — only the owner advances
* the row through its eligible groups. */
export function cascadeLockKey(tableId: string, rowId: string): string {
return `table:cascade:${tableId}:${rowId}`
}
/**
* Run `fn` while holding the row's cascade lock, with a heartbeat extending
* the TTL every 10s so a crashed pod releases the lock in ≤30s. `ownerId`
* must be unique per holder (typically the cell-task's `executionId`) so
* `releaseLock` does compare-and-delete and can't accidentally drop another
* owner's lock.
*
* Returns `'acquired'` after `fn` resolves, or `'contended'` if another
* task already holds the lock — `fn` is NOT invoked in that case. The
* caller decides what to do on contention (cell-task bails; resume worker
* still writes the resumed-group's terminal state but skips the cascade).
*
* NOTE: when Redis is unavailable, `acquireLock` returns `true` as a
* single-replica fallback — concurrent cell-tasks would all "acquire" and
* run in parallel. The cell-write SQL guard mitigates double-writes but
* doesn't prevent duplicate workflow executions.
*/
export async function withCascadeLock<T>(
tableId: string,
rowId: string,
ownerId: string,
fn: () => Promise<T>
): Promise<{ status: 'acquired'; result: T } | { status: 'contended' }> {
const key = cascadeLockKey(tableId, rowId)
const acquired = await acquireLock(key, ownerId, LOCK_TTL_SECONDS)
if (!acquired) return { status: 'contended' }
const heartbeat = setInterval(() => {
extendLock(key, ownerId, LOCK_TTL_SECONDS).catch((err) => {
logger.warn(`Heartbeat refresh failed for ${key}`, { error: toError(err).message })
})
}, HEARTBEAT_INTERVAL_MS)
try {
const result = await fn()
return { status: 'acquired', result }
} finally {
clearInterval(heartbeat)
await releaseLock(key, ownerId).catch((err) => {
logger.warn(`Lock release failed for ${key}`, { error: toError(err).message })
})
}
}