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
381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
import { db } from '@sim/db'
|
|
import type { DataRetentionSettings, WorkspaceMode } from '@sim/db/schema'
|
|
import { organization, workspace } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { tasks } from '@trigger.dev/sdk'
|
|
import { and, asc, eq, gt, isNull } from 'drizzle-orm'
|
|
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
|
|
import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/subscription'
|
|
import { getPlanType, type PlanCategory } from '@/lib/billing/plan-helpers'
|
|
import { type RetentionHoursKey, resolveEffectiveRetentionHours } from '@/lib/billing/retention'
|
|
import { chunkArray } from '@/lib/cleanup/batch-delete'
|
|
import { getJobQueue } from '@/lib/core/async-jobs'
|
|
import { shouldExecuteInline } from '@/lib/core/async-jobs/config'
|
|
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
|
|
import type { EnqueueOptions } from '@/lib/core/async-jobs/types'
|
|
import { isTriggerAvailable } from '@/lib/knowledge/documents/service'
|
|
import { isOrganizationWorkspace, WORKSPACE_MODE } from '@/lib/workspaces/policy'
|
|
|
|
const logger = createLogger('RetentionDispatcher')
|
|
|
|
/** Trigger.dev's documented cap on items per `batchTrigger` call (SDK 4.3.1+). */
|
|
const BATCH_TRIGGER_CHUNK_SIZE = 1000
|
|
const WORKSPACE_SCOPE_PAGE_SIZE = 500
|
|
|
|
/** Bounds per-run memory + DB connections regardless of plan size. */
|
|
const WORKSPACES_PER_CLEANUP_CHUNK = 500
|
|
|
|
export type CleanupJobType = 'cleanup-logs' | 'cleanup-soft-deletes' | 'cleanup-tasks'
|
|
|
|
export type NonEnterprisePlan = Exclude<PlanCategory, 'enterprise'>
|
|
|
|
const NON_ENTERPRISE_PLANS = ['free', 'pro', 'team'] as const satisfies readonly NonEnterprisePlan[]
|
|
|
|
export interface CleanupJobPayload {
|
|
plan: PlanCategory
|
|
workspaceIds: string[]
|
|
retentionHours: number
|
|
label: string
|
|
/** Set on exactly one chunk per dispatch so plan-wide housekeeping runs once. */
|
|
runGlobalHousekeeping?: boolean
|
|
}
|
|
|
|
interface CleanupJobConfig {
|
|
key: RetentionHoursKey
|
|
defaults: Record<PlanCategory, number | null>
|
|
}
|
|
|
|
interface WorkspaceCleanupScopeRow {
|
|
id: string
|
|
billedAccountUserId: string
|
|
organizationId: string | null
|
|
workspaceMode: WorkspaceMode
|
|
organizationSettings: DataRetentionSettings | null
|
|
}
|
|
|
|
const DAY = 24
|
|
|
|
type PlanResolutionEntry = readonly [string, PlanCategory]
|
|
|
|
function getCleanupConcurrencyKey(jobType: CleanupJobType): string {
|
|
return `cleanup:${jobType}`
|
|
}
|
|
|
|
/**
|
|
* Single source of truth for cleanup retention: which key each job type reads
|
|
* from `organization.dataRetentionSettings`, and the default retention (in
|
|
* hours) per plan. Enterprise is always `null` here — enterprise orgs must
|
|
* set their own value.
|
|
*/
|
|
export const CLEANUP_CONFIG = {
|
|
'cleanup-logs': {
|
|
key: 'logRetentionHours',
|
|
defaults: { free: 30 * DAY, pro: null, team: null, enterprise: null },
|
|
},
|
|
'cleanup-soft-deletes': {
|
|
key: 'softDeleteRetentionHours',
|
|
defaults: { free: 30 * DAY, pro: 90 * DAY, team: 90 * DAY, enterprise: null },
|
|
},
|
|
'cleanup-tasks': {
|
|
key: 'taskCleanupHours',
|
|
defaults: { free: null, pro: null, team: null, enterprise: null },
|
|
},
|
|
} as const satisfies Record<CleanupJobType, CleanupJobConfig>
|
|
|
|
async function listActiveWorkspaceCleanupScopeRowsPage(
|
|
afterId: string | null
|
|
): Promise<WorkspaceCleanupScopeRow[]> {
|
|
const rows = await db
|
|
.select({
|
|
id: workspace.id,
|
|
billedAccountUserId: workspace.billedAccountUserId,
|
|
organizationId: workspace.organizationId,
|
|
workspaceMode: workspace.workspaceMode,
|
|
organizationSettings: organization.dataRetentionSettings,
|
|
})
|
|
.from(workspace)
|
|
.leftJoin(organization, eq(organization.id, workspace.organizationId))
|
|
.where(
|
|
afterId
|
|
? and(isNull(workspace.archivedAt), gt(workspace.id, afterId))
|
|
: isNull(workspace.archivedAt)
|
|
)
|
|
.orderBy(asc(workspace.id))
|
|
.limit(WORKSPACE_SCOPE_PAGE_SIZE)
|
|
|
|
return rows.map((row) => ({
|
|
...row,
|
|
organizationSettings: (row.organizationSettings as DataRetentionSettings | null) ?? null,
|
|
}))
|
|
}
|
|
|
|
async function resolvePersonalPlanTypesByBilledUserId(
|
|
rows: WorkspaceCleanupScopeRow[]
|
|
): Promise<Map<string, PlanCategory>> {
|
|
const billedUserIds = Array.from(new Set(rows.map((row) => row.billedAccountUserId)))
|
|
const entries = await Promise.all(
|
|
billedUserIds.map(async (userId) => {
|
|
try {
|
|
const subscription = await getHighestPriorityPersonalSubscription(userId, {
|
|
onError: 'throw',
|
|
})
|
|
return [userId, getPlanType(subscription?.plan)] as const
|
|
} catch (error) {
|
|
logger.error('Skipping cleanup for billed user after plan lookup failed', {
|
|
userId,
|
|
error,
|
|
})
|
|
return null
|
|
}
|
|
})
|
|
)
|
|
|
|
return new Map(entries.filter((entry): entry is PlanResolutionEntry => entry !== null))
|
|
}
|
|
|
|
async function resolvePlanTypesByWorkspaceId(
|
|
rows: WorkspaceCleanupScopeRow[]
|
|
): Promise<Map<string, PlanCategory>> {
|
|
const userScopedRows = rows.filter((row) => row.workspaceMode !== WORKSPACE_MODE.ORGANIZATION)
|
|
const userPlanByBilledUserId = await resolvePersonalPlanTypesByBilledUserId(userScopedRows)
|
|
const entries = await Promise.all(
|
|
rows.map(async (row) => {
|
|
if (row.workspaceMode === WORKSPACE_MODE.ORGANIZATION) {
|
|
const organizationId = isOrganizationWorkspace(row) ? row.organizationId : null
|
|
if (!organizationId) {
|
|
logger.error('Skipping cleanup for malformed organization workspace', {
|
|
workspaceId: row.id,
|
|
organizationId: row.organizationId,
|
|
})
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const subscription = await getOrganizationSubscription(organizationId, {
|
|
onError: 'throw',
|
|
})
|
|
if (!subscription) {
|
|
logger.warn('Skipping cleanup for organization workspace without an org subscription', {
|
|
workspaceId: row.id,
|
|
organizationId,
|
|
})
|
|
return null
|
|
}
|
|
|
|
return [row.id, getPlanType(subscription?.plan)] as const
|
|
} catch (error) {
|
|
logger.error('Skipping cleanup for organization workspace after plan lookup failed', {
|
|
workspaceId: row.id,
|
|
organizationId,
|
|
error,
|
|
})
|
|
return null
|
|
}
|
|
}
|
|
|
|
const plan = userPlanByBilledUserId.get(row.billedAccountUserId)
|
|
if (plan === undefined) {
|
|
return null
|
|
}
|
|
|
|
return [row.id, plan] as const
|
|
})
|
|
)
|
|
|
|
return new Map(entries.filter((entry): entry is PlanResolutionEntry => entry !== null))
|
|
}
|
|
|
|
async function buildCleanupRunner(jobType: CleanupJobType): Promise<EnqueueOptions['runner']> {
|
|
const cleanupRunner = await (async () => {
|
|
switch (jobType) {
|
|
case 'cleanup-logs':
|
|
return (await import('@/background/cleanup-logs')).runCleanupLogs
|
|
case 'cleanup-soft-deletes':
|
|
return (await import('@/background/cleanup-soft-deletes')).runCleanupSoftDeletes
|
|
case 'cleanup-tasks':
|
|
return (await import('@/background/cleanup-tasks')).runCleanupTasks
|
|
}
|
|
})()
|
|
return ((payload) => cleanupRunner(payload as CleanupJobPayload)) as EnqueueOptions['runner']
|
|
}
|
|
|
|
/** Job type → plan whose housekeeping is global, not per-workspace. */
|
|
const GLOBAL_HOUSEKEEPING_PLAN: Partial<Record<CleanupJobType, PlanCategory>> = {
|
|
'cleanup-logs': 'free',
|
|
}
|
|
|
|
async function forEachCleanupChunk(
|
|
jobType: CleanupJobType,
|
|
onChunk: (payload: CleanupJobPayload) => Promise<void>
|
|
): Promise<{ chunkCount: number; workspaceCount: number }> {
|
|
const config = CLEANUP_CONFIG[jobType]
|
|
const chunkCountByPlan: Partial<Record<NonEnterprisePlan, number>> = {}
|
|
const housekeepingPlan = GLOBAL_HOUSEKEEPING_PLAN[jobType]
|
|
let housekeepingAssigned = false
|
|
let workspaceCount = 0
|
|
let chunkCount = 0
|
|
let afterId: string | null = null
|
|
|
|
const emitChunk = async (payload: CleanupJobPayload) => {
|
|
if (payload.plan === housekeepingPlan && !housekeepingAssigned) {
|
|
payload.runGlobalHousekeeping = true
|
|
housekeepingAssigned = true
|
|
}
|
|
chunkCount++
|
|
await onChunk(payload)
|
|
}
|
|
|
|
while (true) {
|
|
const rows = await listActiveWorkspaceCleanupScopeRowsPage(afterId)
|
|
if (rows.length === 0) break
|
|
|
|
afterId = rows[rows.length - 1].id
|
|
const planByWorkspaceId = await resolvePlanTypesByWorkspaceId(rows)
|
|
|
|
for (const plan of NON_ENTERPRISE_PLANS) {
|
|
const retentionHours = config.defaults[plan]
|
|
if (retentionHours === null) continue
|
|
|
|
const workspaceIds = rows
|
|
.filter((row) => planByWorkspaceId.get(row.id) === plan)
|
|
.map((row) => row.id)
|
|
if (workspaceIds.length === 0) continue
|
|
|
|
workspaceCount += workspaceIds.length
|
|
const planChunks = chunkArray(workspaceIds, WORKSPACES_PER_CLEANUP_CHUNK)
|
|
for (const ws of planChunks) {
|
|
const chunkNumber = (chunkCountByPlan[plan] ?? 0) + 1
|
|
chunkCountByPlan[plan] = chunkNumber
|
|
await emitChunk({
|
|
plan,
|
|
workspaceIds: ws,
|
|
retentionHours,
|
|
label: `${plan}/${chunkNumber}`,
|
|
})
|
|
}
|
|
}
|
|
|
|
for (const row of rows) {
|
|
if (planByWorkspaceId.get(row.id) !== 'enterprise') continue
|
|
const hours = resolveEffectiveRetentionHours({
|
|
orgSettings: row.organizationSettings,
|
|
workspaceId: row.id,
|
|
key: config.key,
|
|
})
|
|
if (hours == null) continue
|
|
workspaceCount++
|
|
await emitChunk({
|
|
plan: 'enterprise',
|
|
workspaceIds: [row.id],
|
|
retentionHours: hours,
|
|
label: `enterprise/${row.id}`,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (housekeepingPlan && housekeepingPlan !== 'enterprise' && !housekeepingAssigned) {
|
|
const retentionHours = config.defaults[housekeepingPlan]
|
|
if (retentionHours != null) {
|
|
await emitChunk({
|
|
plan: housekeepingPlan,
|
|
workspaceIds: [],
|
|
retentionHours,
|
|
label: `${housekeepingPlan}/housekeeping`,
|
|
runGlobalHousekeeping: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
return { chunkCount, workspaceCount }
|
|
}
|
|
|
|
/**
|
|
* Resolve the workspace set + retention cutoff once, then fan out one task
|
|
* run per `WORKSPACES_PER_CLEANUP_CHUNK` workspaces via `tasks.batchTrigger`.
|
|
* Falls back to `JobQueueBackend` enqueue when Trigger.dev isn't available.
|
|
*/
|
|
export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{
|
|
jobIds: string[]
|
|
jobCount: number
|
|
chunkCount: number
|
|
workspaceCount: number
|
|
}> {
|
|
const jobIds: string[] = []
|
|
let succeeded = 0
|
|
let failed = 0
|
|
|
|
if (isTriggerAvailable()) {
|
|
let batch: CleanupJobPayload[] = []
|
|
const flushBatch = async () => {
|
|
if (batch.length === 0) return
|
|
const currentBatch = batch
|
|
batch = []
|
|
const region = await resolveTriggerRegion()
|
|
const batchResult = await tasks.batchTrigger(
|
|
jobType,
|
|
currentBatch.map((payload) => ({
|
|
payload,
|
|
options: {
|
|
tags: [`plan:${payload.plan}`, `jobType:${jobType}`],
|
|
concurrencyKey: getCleanupConcurrencyKey(jobType),
|
|
region,
|
|
},
|
|
}))
|
|
)
|
|
jobIds.push(batchResult.batchId)
|
|
succeeded += currentBatch.length
|
|
}
|
|
|
|
const { chunkCount, workspaceCount } = await forEachCleanupChunk(jobType, async (payload) => {
|
|
batch.push(payload)
|
|
if (batch.length >= BATCH_TRIGGER_CHUNK_SIZE) {
|
|
await flushBatch()
|
|
}
|
|
})
|
|
await flushBatch()
|
|
|
|
logger.info(
|
|
`[${jobType}] Trigger cleanup chunks: ${succeeded} dispatched in ${jobIds.length} batch(es)`
|
|
)
|
|
return { jobIds, jobCount: jobIds.length, chunkCount, workspaceCount }
|
|
}
|
|
|
|
const inlineRunner = shouldExecuteInline() ? await buildCleanupRunner(jobType) : undefined
|
|
if (inlineRunner) {
|
|
const { chunkCount, workspaceCount } = await forEachCleanupChunk(jobType, async (payload) => {
|
|
try {
|
|
await inlineRunner(payload, new AbortController().signal)
|
|
jobIds.push(`inline:${jobType}:${payload.label}`)
|
|
succeeded++
|
|
} catch (error) {
|
|
failed++
|
|
logger.error(`[${jobType}] Inline cleanup chunk failed:`, {
|
|
plan: payload.plan,
|
|
label: payload.label,
|
|
error,
|
|
})
|
|
}
|
|
})
|
|
|
|
logger.info(`[${jobType}] Inline cleanup chunks: ${succeeded} succeeded, ${failed} failed`)
|
|
return { jobIds, jobCount: jobIds.length, chunkCount, workspaceCount }
|
|
}
|
|
|
|
const jobQueue = await getJobQueue()
|
|
const { chunkCount, workspaceCount } = await forEachCleanupChunk(jobType, async (payload) => {
|
|
try {
|
|
const jobId = await jobQueue.enqueue(jobType, payload, {
|
|
concurrencyKey: getCleanupConcurrencyKey(jobType),
|
|
})
|
|
jobIds.push(jobId)
|
|
succeeded++
|
|
} catch (reason) {
|
|
failed++
|
|
logger.error(`[${jobType}] Failed to enqueue chunk:`, { reason })
|
|
}
|
|
})
|
|
logger.info(`[${jobType}] Chunk enqueue: ${succeeded} succeeded, ${failed} failed`)
|
|
|
|
return { jobIds, jobCount: jobIds.length, chunkCount, workspaceCount }
|
|
}
|