[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
---
|
||||
id: backend-cron-scheduler-patterns
|
||||
title: Cron / Scheduler — Quartz / cron / Inngest / Trigger.dev
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [backend, cron, scheduler, vibe-coding]
|
||||
tech_stack: { language: "TS / Python", applicable_to: ["Backend"] }
|
||||
applied_in: []
|
||||
aliases: [cron, scheduler, Inngest, Trigger.dev, BullMQ, Sidekiq, Temporal cron, distributed cron]
|
||||
---
|
||||
|
||||
# Cron / Scheduler
|
||||
|
||||
> "매 X 시간 작업 실행". **Cron 가 simple, BullMQ / Inngest / Trigger.dev / Temporal 가 modern**. Distributed lock + retry + observability.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- Cron expression: `0 * * * *`.
|
||||
- Single instance vs distributed.
|
||||
- Missed run 처리.
|
||||
- Retry / dedup / idempotency.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### Linux cron (가장 simple)
|
||||
```cron
|
||||
# crontab -e
|
||||
0 2 * * * /usr/bin/node /app/cleanup.js >> /var/log/cleanup.log 2>&1
|
||||
```
|
||||
|
||||
→ 매일 2 AM. 1 server 만.
|
||||
|
||||
### Cron expression
|
||||
```
|
||||
* * * * *
|
||||
| | | | |
|
||||
| | | | +-- Day of week (0-6, Sun=0)
|
||||
| | | +---- Month (1-12)
|
||||
| | +------ Day of month (1-31)
|
||||
| +-------- Hour (0-23)
|
||||
+---------- Minute (0-59)
|
||||
|
||||
0 * * * * 매 hour
|
||||
*/5 * * * * 매 5 min
|
||||
0 9 * * 1-5 평일 9 AM
|
||||
0 0 1 * * 매월 1일 자정
|
||||
```
|
||||
|
||||
→ crontab.guru 가 검증.
|
||||
|
||||
### Node-cron (in-process)
|
||||
```ts
|
||||
import cron from 'node-cron';
|
||||
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
await cleanupOldData();
|
||||
});
|
||||
```
|
||||
|
||||
→ Single instance only. Restart 시 잃음.
|
||||
|
||||
### BullMQ (Redis-backed)
|
||||
```ts
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
|
||||
const queue = new Queue('jobs', { connection: { host: 'redis' } });
|
||||
|
||||
// Repeating job
|
||||
await queue.add('cleanup', null, {
|
||||
repeat: { pattern: '0 * * * *' },
|
||||
});
|
||||
|
||||
// Worker
|
||||
new Worker('jobs', async (job) => {
|
||||
if (job.name === 'cleanup') await cleanup();
|
||||
}, { connection });
|
||||
```
|
||||
|
||||
→ Multi-instance OK (Redis 가 lock). Persistent.
|
||||
|
||||
### Distributed cron 의 문제
|
||||
```
|
||||
2 instance × 같은 cron = 2번 실행.
|
||||
|
||||
해결:
|
||||
1. Lock (Redis / DB).
|
||||
2. Single leader.
|
||||
3. Job queue.
|
||||
```
|
||||
|
||||
### Distributed lock (Redis)
|
||||
```ts
|
||||
async function withLock(key: string, ttl: number, fn: () => Promise<void>) {
|
||||
const got = await redis.set(`lock:${key}`, 'taken', 'EX', ttl, 'NX');
|
||||
if (!got) return; // 다른 instance
|
||||
|
||||
try { await fn(); } finally { await redis.del(`lock:${key}`); }
|
||||
}
|
||||
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
await withLock('cleanup', 3600, async () => {
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Inngest (modern, function-as-a-service)
|
||||
```ts
|
||||
import { Inngest } from 'inngest';
|
||||
const inngest = new Inngest({ name: 'My App' });
|
||||
|
||||
export const dailyReport = inngest.createFunction(
|
||||
{ id: 'daily-report' },
|
||||
{ cron: '0 9 * * *' },
|
||||
async ({ event, step }) => {
|
||||
const data = await step.run('fetch', () => fetchData());
|
||||
await step.run('email', () => sendEmail(data));
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
→ Step = retry + dedup automatic.
|
||||
|
||||
### Trigger.dev (alternative)
|
||||
```ts
|
||||
import { client } from './trigger';
|
||||
|
||||
client.defineJob({
|
||||
id: 'daily-report',
|
||||
trigger: cronTrigger({ cron: '0 9 * * *' }),
|
||||
run: async (payload, io) => {
|
||||
const data = await io.runTask('fetch', async () => fetchData());
|
||||
await io.sendEvent('email', { data });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Temporal (workflow engine)
|
||||
```ts
|
||||
// Schedule
|
||||
import { Connection, ScheduleClient } from '@temporalio/client';
|
||||
|
||||
const client = new ScheduleClient({ connection });
|
||||
|
||||
await client.create({
|
||||
scheduleId: 'daily-cleanup',
|
||||
spec: { intervals: [{ every: '1 day' }] },
|
||||
action: {
|
||||
type: 'startWorkflow',
|
||||
workflowType: 'cleanupWorkflow',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
→ Cron + workflow + retry.
|
||||
|
||||
### Sidekiq (Ruby)
|
||||
```ruby
|
||||
# Sidekiq scheduler
|
||||
Sidekiq.configure_server do |config|
|
||||
config.on(:startup) do
|
||||
Sidekiq.schedule = YAML.load_file('config/schedule.yml')
|
||||
Sidekiq::Scheduler.reload_schedule!
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
```yaml
|
||||
# schedule.yml
|
||||
daily_report:
|
||||
cron: '0 9 * * *'
|
||||
class: ReportWorker
|
||||
```
|
||||
|
||||
### AWS EventBridge + Lambda
|
||||
```yaml
|
||||
# Serverless
|
||||
functions:
|
||||
daily:
|
||||
handler: handler.daily
|
||||
events:
|
||||
- schedule:
|
||||
rate: cron(0 9 * * ? *)
|
||||
```
|
||||
|
||||
→ Managed cron. Lambda 친화.
|
||||
|
||||
### GCP Cloud Scheduler
|
||||
```bash
|
||||
gcloud scheduler jobs create http daily-report \
|
||||
--schedule "0 9 * * *" \
|
||||
--uri https://api.example.com/cron/daily \
|
||||
--http-method POST
|
||||
```
|
||||
|
||||
→ HTTP trigger.
|
||||
|
||||
### Cloudflare Workers Cron
|
||||
```toml
|
||||
# wrangler.toml
|
||||
[triggers]
|
||||
crons = ["0 9 * * *", "*/5 * * * *"]
|
||||
```
|
||||
|
||||
```ts
|
||||
export default {
|
||||
async scheduled(event, env, ctx) {
|
||||
if (event.cron === '0 9 * * *') await dailyReport();
|
||||
if (event.cron === '*/5 * * * *') await checkHealth();
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
→ Edge cron.
|
||||
|
||||
### Vercel Cron
|
||||
```json
|
||||
// vercel.json
|
||||
{
|
||||
"crons": [
|
||||
{ "path": "/api/cron", "schedule": "0 9 * * *" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// app/api/cron/route.ts
|
||||
export async function GET(request) {
|
||||
const auth = request.headers.get('authorization');
|
||||
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
await dailyReport();
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
```
|
||||
|
||||
### Idempotency
|
||||
```ts
|
||||
async function cleanup() {
|
||||
// 매 run 가 idempotent — retry safe.
|
||||
await db.exec('DELETE FROM logs WHERE created_at < NOW() - INTERVAL 30 DAY');
|
||||
}
|
||||
```
|
||||
|
||||
→ "다시 실행해도 OK".
|
||||
|
||||
### Missed run
|
||||
```
|
||||
Server down 1 hour → 1 cron 가 missed.
|
||||
|
||||
처리:
|
||||
1. 무시 (간단).
|
||||
2. Catch up (다음 run 가 이전 work 도).
|
||||
3. Manual trigger.
|
||||
|
||||
→ Inngest / Temporal 가 missed-run 처리.
|
||||
```
|
||||
|
||||
### Backfill
|
||||
```ts
|
||||
// 옛 날짜 cron 재실행
|
||||
for (const date of getDateRange('2026-04-01', '2026-04-30')) {
|
||||
await dailyReport({ date });
|
||||
}
|
||||
```
|
||||
|
||||
### Timezone
|
||||
```cron
|
||||
0 9 * * *
|
||||
# Server timezone (UTC).
|
||||
```
|
||||
|
||||
```ts
|
||||
// Asia/Seoul = UTC+9
|
||||
// 9 AM KST = 0 AM UTC
|
||||
'0 0 * * *' in UTC = 9 AM KST.
|
||||
```
|
||||
|
||||
→ Inngest / Trigger.dev 가 timezone 옵션.
|
||||
|
||||
### Frequency limit
|
||||
```
|
||||
✓ 매일, 매 시간: 일반.
|
||||
✗ 매 분: high-volume — queue 더 좋음.
|
||||
✗ 매 초: bad pattern.
|
||||
|
||||
→ "매 분 미만" = queue / streaming.
|
||||
```
|
||||
|
||||
### Job 의 visibility
|
||||
```
|
||||
- 매 run 의 status (running / done / failed)
|
||||
- Duration / retry count
|
||||
- Last successful run
|
||||
- Next scheduled
|
||||
|
||||
→ Inngest / Trigger.dev / Temporal UI.
|
||||
```
|
||||
|
||||
### DB 의 schedule (own)
|
||||
```sql
|
||||
CREATE TABLE scheduled_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
cron TEXT,
|
||||
last_run TIMESTAMP,
|
||||
next_run TIMESTAMP,
|
||||
status TEXT
|
||||
);
|
||||
|
||||
-- 매 분 worker
|
||||
SELECT * FROM scheduled_jobs WHERE next_run <= NOW();
|
||||
-- → 매 job 실행, last_run / next_run update.
|
||||
```
|
||||
|
||||
→ DIY 의 simple version.
|
||||
|
||||
### Retry policy
|
||||
```ts
|
||||
// BullMQ
|
||||
queue.add('cleanup', null, {
|
||||
repeat: { pattern: '0 * * * *' },
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 1000 },
|
||||
});
|
||||
```
|
||||
|
||||
### Dependent cron
|
||||
```
|
||||
dailyReport 가 dailyClean 후.
|
||||
|
||||
→ Workflow (Temporal / Inngest) 가 좋음.
|
||||
순서 + dependency + retry 가 자동.
|
||||
```
|
||||
|
||||
### Cron alert
|
||||
```
|
||||
- 매 run 의 metric (Prometheus).
|
||||
- "Last run > expected interval" alert.
|
||||
- Failure count threshold.
|
||||
|
||||
→ Datadog / Grafana.
|
||||
```
|
||||
|
||||
### Cost
|
||||
```
|
||||
Lambda + EventBridge: $ 작음 ($0.01 / month / cron).
|
||||
Inngest: $0 (1k step / month free).
|
||||
Trigger.dev: 비슷.
|
||||
Vercel: 무료 (Pro 가 1 cron job).
|
||||
Self-host: server cost.
|
||||
|
||||
→ Cron 가 비싼 보다 missing run 가 비싼.
|
||||
```
|
||||
|
||||
### When?
|
||||
```
|
||||
Simple (1 server): node-cron.
|
||||
Multi-instance: BullMQ / Redis lock.
|
||||
Modern serverless: Inngest / Trigger.dev / Vercel Cron.
|
||||
Workflow: Temporal.
|
||||
Cloud: AWS EventBridge / GCP Scheduler.
|
||||
Edge: Cloudflare Cron.
|
||||
```
|
||||
|
||||
### Real-world
|
||||
```
|
||||
Examples:
|
||||
- Daily report email (9 AM)
|
||||
- Weekly digest
|
||||
- Monthly billing
|
||||
- Cleanup old data (1 AM)
|
||||
- Health check (매 5 min)
|
||||
- Sync (매 hour)
|
||||
```
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 상황 | 추천 |
|
||||
|---|---|
|
||||
| 1 server | node-cron / Linux cron |
|
||||
| Multi-instance | BullMQ / Redis lock |
|
||||
| Modern serverless | Inngest / Trigger.dev |
|
||||
| Workflow | Temporal |
|
||||
| AWS | EventBridge + Lambda |
|
||||
| Vercel | Vercel Cron |
|
||||
| Edge | Cloudflare Cron |
|
||||
| 매우 frequent | Queue / streaming |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **No lock (multi-instance)**: 중복.
|
||||
- **No idempotency**: retry = corrupt.
|
||||
- **No alert**: silent miss.
|
||||
- **매 1 sec cron**: queue 사용.
|
||||
- **Timezone confusion**: 9 AM ≠ 9 AM.
|
||||
- **No visibility**: blind.
|
||||
- **Missed run 무시**: data 잃음.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- 단순 = node-cron / Linux.
|
||||
- Modern = Inngest / Trigger.dev (function-as-a-service).
|
||||
- Multi-instance = BullMQ + lock.
|
||||
- Workflow = Temporal.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[Backend_Cron_Patterns]]
|
||||
- [[Backend_Job_Queue_Patterns]]
|
||||
- [[Backend_Job_Scheduling_Temporal]]
|
||||
Reference in New Issue
Block a user