Skip to content

Pattern: Scheduled Lambda with Observability

Run periodic maintenance or export jobs on a cron or rate schedule using defineScheduledHandler, with structured metrics and tracing throughout.

The Pattern

typescript
// src/lambdas/scheduled/CleanupExpiredRecords/index.ts
import { and, eq, lt, or } from '@mantleframework/database/orm'
import { getDrizzleClient } from '#db/client'
import { fileDownloads, sessions, verification } from '#db/schema'
import { defineScheduledHandler } from '@mantleframework/core'
import {
  addMetadata, endSpan, logError, logInfo,
  metrics, MetricUnit, startSpan,
} from '@mantleframework/observability'
import { DownloadStatus } from '#types/enums'
import { secondsAgo, TIME } from '#utils/time'

async function cleanupFileDownloads(): Promise<number> {
  const db = await getDrizzleClient()
  const cutoffTime = secondsAgo(TIME.DAY_SEC)
  const result = await db.delete(fileDownloads).where(
    and(
      or(
        eq(fileDownloads.status, DownloadStatus.Completed),
        eq(fileDownloads.status, DownloadStatus.Failed),
      ),
      lt(fileDownloads.updatedAt, cutoffTime),
    ),
  ).returning({ fileId: fileDownloads.fileId })
  return result.length
}

const scheduled = defineScheduledHandler({
  operationName: 'CleanupExpiredRecords',
  schedule: { expression: 'cron(0 3 * * ? *)' }, // daily at 3 AM UTC
  timeout: 60,
})

export const handler = scheduled(async (): Promise<CleanupResult> => {
  metrics.addMetric('CleanupRun', MetricUnit.Count, 1)
  const span = startSpan('cleanup-records')

  const result: CleanupResult = {
    fileDownloadsDeleted: 0,
    sessionsDeleted: 0,
    verificationTokensDeleted: 0,
    errors: [],
  }

  try {
    result.fileDownloadsDeleted = await cleanupFileDownloads()
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error)
    logError('Failed to cleanup file downloads', { error: message })
    result.errors.push(`FileDownloads cleanup failed: ${message}`)
  }

  const totalDeleted =
    result.fileDownloadsDeleted +
    result.sessionsDeleted +
    result.verificationTokensDeleted

  metrics.addMetric('RecordsCleanedUp', MetricUnit.Count, totalDeleted)
  addMetadata(span, 'totalDeleted', totalDeleted)
  addMetadata(span, 'errors', result.errors.length)
  endSpan(span)

  logInfo('CleanupExpiredRecords completed', result as unknown as Record<string, unknown>)
  return result
})

How It Works

defineScheduledHandler wraps the handler function and generates a CloudWatch Events rule in Terraform. The schedule.expression accepts either a cron(...) expression or a rate(...) string. The handler receives a ScheduledEvent and returns any serializable value — Mantle logs it automatically. Each cleanup step is wrapped in its own try/catch so a failure in one table doesn't abort the others; errors accumulate in the result and are reported via structured logging. EMF metrics are emitted per-run so CloudWatch dashboards show cleanup trends over time.

Real-World Usage

Configuration

No extra mantle.config.ts entries needed — the schedule expression lives directly in the defineScheduledHandler call. Mantle's infra generator reads schedule.expression and creates the CloudWatch Events rule automatically.

typescript
// cron expression — runs at a fixed UTC time
const scheduled = defineScheduledHandler({
  operationName: 'CleanupExpiredRecords',
  schedule: { expression: 'cron(0 3 * * ? *)' },
  timeout: 60,
})

// rate expression — runs on a fixed interval
const scheduled = defineScheduledHandler({
  operationName: 'PruneDevices',
  schedule: { expression: 'rate(1 day)' },
  timeout: 300,
})

Variations

Scheduled S3 export: Combine defineScheduledHandler with exportToS3 from @mantleframework/aws to snapshot database state on a schedule, then emit a follow-up event:

typescript
import { exportToS3 } from '@mantleframework/aws'
import { emitEvent, defineScheduledHandler } from '@mantleframework/core'
import { getRequiredEnv } from '@mantleframework/env'

const scheduled = defineScheduledHandler({
  operationName: 'ExportDailySnapshot',
  schedule: { expression: 'cron(0 2 * * ? *)' },
  timeout: 60,
})

export const handler = scheduled(async () => {
  const bucket = getRequiredEnv('DATA_BUCKET')
  const generatedAt = new Date().toISOString()

  await exportToS3({ bucket, key: 'snapshots/daily.json', data: { generatedAt, items: [] } })
  await emitEvent({ detailType: 'DailySnapshotCompleted', detail: { generatedAt } })

  return { generatedAt }
})

Lambda config extras (secrets, static env vars): If the scheduled job needs secrets, add a defineLambda call before the handler. See PruneDevices/index.ts for an example with APNS credentials injected via defineLambda({ secrets: { ... } }).