Files
simstudioai--sim/apps/sim/lib/table/events.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

368 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Per-table event buffer for live cell-state updates.
*
* The grid subscribes to a per-table SSE stream and patches its React Query
* cache as events arrive. This buffer is the durable mid-tier between the
* cell-write paths (`writeWorkflowGroupState`, `cancelWorkflowGroupRuns`) and
* the SSE consumers — every status transition appends here with a monotonic
* eventId; SSE clients resume on reconnect via `?from=<lastEventId>` and the
* server replays from this buffer.
*
* Modeled after `apps/sim/lib/execution/event-buffer.ts` but stripped of
* complexity tables don't need: no per-execution lifecycle, no id reservation
* batching, no write-queue serialization. Tables are always-on; cell writes
* are sparse and independent.
*/
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { env } from '@/lib/core/config/env'
import { getRedisClient } from '@/lib/core/config/redis'
const logger = createLogger('TableEventBuffer')
const REDIS_PREFIX = 'table:stream:'
export const TABLE_EVENT_TTL_SECONDS = 60 * 60 // 1 hour
export const TABLE_EVENT_CAP = 5000
/** Max events returned by a single read; the SSE route drains in chunks. */
export const TABLE_EVENT_READ_CHUNK = 500
/**
* Atomic append: INCR the seq counter to mint a new eventId, build the entry
* JSON inline, ZADD it, refresh TTL on events + seq + meta, trim to cap, then
* write the resulting earliestEventId to meta. Single round-trip per event.
* Without atomicity a slow reader could observe the trim before the meta
* update and miss the prune signal.
*
* KEYS: [events, seq, meta]
* ARGV: [ttlSec, cap, updatedAtIso, entryPrefix, entrySuffix]
* The new eventId is spliced between prefix/suffix to form the entry JSON.
* Returns the new eventId.
*/
const APPEND_EVENT_SCRIPT = `
local eventId = redis.call('INCR', KEYS[2])
local entry = ARGV[4] .. eventId .. ARGV[5]
redis.call('ZADD', KEYS[1], eventId, entry)
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[1]))
redis.call('ZREMRANGEBYRANK', KEYS[1], 0, -tonumber(ARGV[2]) - 1)
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
if oldest[2] then
redis.call('HSET', KEYS[3], 'earliestEventId', tostring(math.floor(tonumber(oldest[2]))), 'updatedAt', ARGV[3])
redis.call('EXPIRE', KEYS[3], tonumber(ARGV[1]))
end
return eventId
`
function getEventsKey(tableId: string) {
return `${REDIS_PREFIX}${tableId}:events`
}
function getSeqKey(tableId: string) {
return `${REDIS_PREFIX}${tableId}:seq`
}
function getMetaKey(tableId: string) {
return `${REDIS_PREFIX}${tableId}:meta`
}
export type TableCellStatus = 'pending' | 'queued' | 'running' | 'completed' | 'cancelled' | 'error'
export type TableDispatchStatus = 'pending' | 'dispatching' | 'complete' | 'cancelled'
export type TableEvent =
| {
kind: 'cell'
tableId: string
rowId: string
groupId: string
status: TableCellStatus
executionId: string | null
jobId: string | null
error: string | null
/**
* Present when this transition wrote new output values; absent on
* pure-status transitions (queued, running, cancelled). The publisher
* already has these in hand from the same updateRow call that wrote DB.
*/
outputs?: Record<string, unknown>
/**
* Block-level metadata the renderer reads to distinguish "running" (some
* block actively executing) from "pending-upstream" (run started but this
* column's block hasn't fired yet). The worker fills these on partial
* writes; without them the cell stays on the amber Pending pill.
*/
runningBlockIds?: string[]
blockErrors?: Record<string, string>
}
| {
/** Dispatcher status signal emitted by `dispatcherStep` and the cancel
* path. Drives the client-side "about to run" overlay for rows the
* dispatcher hasn't reached yet. `scope` + `cursor` + `mode` +
* `isManualRun` are carried on every transition so the client can
* upsert without refetching the dispatches list. */
kind: 'dispatch'
tableId: string
dispatchId: string
status: TableDispatchStatus
scope?: { groupIds: string[]; rowIds?: string[] }
cursor?: number
mode?: 'all' | 'incomplete' | 'new'
isManualRun?: boolean
/** Present when the run is capped — carried so the client overlay can
* skip capped dispatches (see `resolveCellExec`). */
limit?: { type: 'rows'; max: number }
}
| {
/** Async background-job progress. Import and delete workers emit `running`
* ticks as batches commit, then a terminal `ready`/`failed`/`canceled`.
* `type` discriminates the work. The client reveals hidden import rows on
* `ready`, and on a delete `failed`/`canceled` restores optimistically
* hidden rows. See `import-runner.ts` / `delete-runner.ts`. */
kind: 'job'
tableId: string
jobId: string
type: 'import' | 'delete' | 'export' | 'backfill' | 'update'
status: 'running' | 'ready' | 'failed' | 'canceled'
/** Rows processed so far (running) or in total (ready). */
progress?: number
/** Byte-based completion percent (0100) — exact and monotonic, for the determinate bar. */
percent?: number
error?: string
}
| {
/** A dispatch was stopped because the billed account is over its usage
* limit. The client surfaces an upgrade prompt and redirects to billing.
* The dispatch is halted via `markDispatchComplete` and the blocked
* cells' pre-stamps are cleared so they revert to un-run. `dispatchId`
* is absent for cascade/auto-fire payloads with no owning dispatch. */
kind: 'usageLimitReached'
tableId: string
dispatchId?: string
message: string
}
export interface TableEventEntry {
eventId: number
tableId: string
event: TableEvent
}
export type TableEventsReadResult =
| { status: 'ok'; events: TableEventEntry[] }
| { status: 'pruned'; earliestEventId: number | undefined }
| { status: 'unavailable'; error: string }
/** In-memory fallback for dev/tests when Redis isn't configured. */
interface MemoryTableStream {
events: TableEventEntry[]
earliestEventId?: number
nextEventId: number
expiresAt: number
}
const memoryTableStreams = new Map<string, MemoryTableStream>()
function canUseMemoryBuffer(): boolean {
return typeof window === 'undefined' && !env.REDIS_URL
}
function pruneExpiredMemoryStreams(now = Date.now()): void {
for (const [tableId, stream] of memoryTableStreams) {
if (stream.expiresAt <= now) {
memoryTableStreams.delete(tableId)
}
}
}
function getMemoryStream(tableId: string): MemoryTableStream {
pruneExpiredMemoryStreams()
let stream = memoryTableStreams.get(tableId)
if (!stream) {
stream = {
events: [],
nextEventId: 1,
expiresAt: Date.now() + TABLE_EVENT_TTL_SECONDS * 1000,
}
memoryTableStreams.set(tableId, stream)
}
return stream
}
function appendMemory(event: TableEvent): TableEventEntry {
const stream = getMemoryStream(event.tableId)
const entry: TableEventEntry = {
eventId: stream.nextEventId++,
tableId: event.tableId,
event,
}
stream.events.push(entry)
if (stream.events.length > TABLE_EVENT_CAP) {
stream.events = stream.events.slice(-TABLE_EVENT_CAP)
stream.earliestEventId = stream.events[0]?.eventId
}
stream.expiresAt = Date.now() + TABLE_EVENT_TTL_SECONDS * 1000
return entry
}
function readMemory(tableId: string, afterEventId: number): TableEventsReadResult {
pruneExpiredMemoryStreams()
const stream = memoryTableStreams.get(tableId)
if (!stream) {
// Mirror the Redis path: a non-zero afterEventId with no buffer at all
// means TTL expired or the stream never existed; either way the caller's
// cursor is stale.
if (afterEventId > 0) return { status: 'pruned', earliestEventId: undefined }
return { status: 'ok', events: [] }
}
if (stream.earliestEventId !== undefined && afterEventId + 1 < stream.earliestEventId) {
return { status: 'pruned', earliestEventId: stream.earliestEventId }
}
return {
status: 'ok',
events: stream.events
.filter((entry) => entry.eventId > afterEventId)
.slice(0, TABLE_EVENT_READ_CHUNK),
}
}
/**
* Append an event to the table's buffer. Fire-and-forget from the caller —
* this never throws, returns null on failure. A Redis blip must not fail a
* cell-write.
*/
export async function appendTableEvent(event: TableEvent): Promise<TableEventEntry | null> {
const redis = getRedisClient()
if (!redis) {
if (canUseMemoryBuffer()) {
try {
return appendMemory(event)
} catch (error) {
logger.warn('appendTableEvent: memory append failed', {
tableId: event.tableId,
error: toError(error).message,
})
return null
}
}
return null
}
try {
// Build the entry JSON in two halves so Lua can splice the new eventId
// between them without us needing a round-trip just to mint the id first.
const tail = `,"tableId":${JSON.stringify(event.tableId)},"event":${JSON.stringify(event)}}`
const head = `{"eventId":`
const result = await redis.eval(
APPEND_EVENT_SCRIPT,
3,
getEventsKey(event.tableId),
getSeqKey(event.tableId),
getMetaKey(event.tableId),
TABLE_EVENT_TTL_SECONDS,
TABLE_EVENT_CAP,
new Date().toISOString(),
head,
tail
)
const eventId = typeof result === 'number' ? result : Number(result)
if (!Number.isFinite(eventId)) return null
return { eventId, tableId: event.tableId, event }
} catch (error) {
logger.warn('appendTableEvent: Redis append failed', {
tableId: event.tableId,
error: toError(error).message,
})
return null
}
}
/**
* The latest eventId assigned for a table, or 0 when the buffer is empty or
* expired. Used by the stream route to tail from "now" when a client connects
* without a replay cursor (fresh mount — its caches were just fetched from
* the DB, so replaying history would only rewind them).
*
* Redis errors propagate: silently falling back to 0 would replay the whole
* buffer over fresh state — the exact churn tail-from-latest exists to avoid.
* The stream route errors the stream instead and the client reconnects with
* backoff.
*/
export async function getLatestTableEventId(tableId: string): Promise<number> {
const redis = getRedisClient()
if (!redis) {
if (canUseMemoryBuffer()) {
// Pure read — getMemoryStream() would allocate a stream as a side effect.
const stream = memoryTableStreams.get(tableId)
return stream ? stream.nextEventId - 1 : 0
}
return 0
}
const raw = await redis.get(getSeqKey(tableId))
if (!raw) return 0
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
}
/**
* Read events for a table where eventId > afterEventId. Returns 'pruned' if
* the caller has fallen off the back of the buffer (TTL expired or cap rolled
* past their lastEventId). Caller should respond by full-refetching from DB
* and resuming streaming from the new earliestEventId.
*/
export async function readTableEventsSince(
tableId: string,
afterEventId: number
): Promise<TableEventsReadResult> {
const redis = getRedisClient()
if (!redis) {
if (canUseMemoryBuffer()) {
return readMemory(tableId, afterEventId)
}
return { status: 'unavailable', error: 'Redis client unavailable' }
}
try {
const meta = await redis.hgetall(getMetaKey(tableId))
const earliestEventId =
meta?.earliestEventId !== undefined ? Number(meta.earliestEventId) : undefined
if (earliestEventId !== undefined && afterEventId + 1 < earliestEventId) {
return { status: 'pruned', earliestEventId }
}
// Read in capped chunks so a 5000-event backlog doesn't materialize as one
// multi-MB Redis reply + JSON parse + SSE flush. The route loop drains
// chunks across ticks.
const raw = await redis.zrangebyscore(
getEventsKey(tableId),
afterEventId + 1,
'+inf',
'LIMIT',
0,
TABLE_EVENT_READ_CHUNK
)
if (raw.length === 0 && afterEventId > 0) {
// Total TTL expiry: events + meta both gone. The seq counter has the
// same TTL — its absence means the buffer was wiped and the caller's
// `afterEventId` is stale. Signal pruned so the client refetches.
const seqExists = await redis.exists(getSeqKey(tableId))
if (seqExists === 0) {
return { status: 'pruned', earliestEventId: undefined }
}
}
return {
status: 'ok',
events: raw
.map((entry) => {
try {
return JSON.parse(entry) as TableEventEntry
} catch {
return null
}
})
.filter((entry): entry is TableEventEntry => Boolean(entry)),
}
} catch (error) {
const message = toError(error).message
logger.warn('readTableEventsSince failed', { tableId, error: message })
return { status: 'unavailable', error: message }
}
}