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
// 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
- Daily record cleanup:
src/lambdas/scheduled/CleanupExpiredRecords/index.ts - Daily device pruning (with
rate()schedule):src/lambdas/scheduled/PruneDevices/index.ts
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.
// 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:
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: { ... } }).