Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/servers/node-hono-sqlite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ enable it with:
import { croner } from '@tinyrack/tinyauth-server/scheduler/croner';

await createApp({
scheduler: croner({ cron: '0 2 * * *' }),
scheduler: croner({ cleanupCron: '0 2 * * *' }),
});
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ Within each localized `content` item, `type` must be `link` or `text`.
| `description` | `string` | `'OpenID Connect Provider API'` | OpenAPI document description. |
| `ui_title` | `string` | `'TinyAuth API Reference'` | Browser page title for the live Scalar API reference UI. |

## `scheduler`

| Field | Type | Default | Description |
|------|------|--------|------|
| `enabled` | `boolean` | `true` | Enables the internal scheduler. |
| `mode` | `'croner' \| 'database'` | `'croner'` | Scheduler backend. Use `database` for multi-instance lease-based execution. |
| `cleanup_cron` | `string` | `'0 2 * * *'` | Cron expression for built-in cleanup jobs. |
| `poll_interval_ms` | `number` | `5000` | Database scheduler polling interval in milliseconds. |
| `lock_ttl_ms` | `number` | `60000` | Database scheduler lease TTL in milliseconds. |
| `background_retry_delay_ms` | `number` | `1000` | Database scheduler retry delay for failed background jobs in milliseconds. |
| `background_max_attempts` | `integer` | `3` | Database scheduler maximum attempts before a background job is marked failed. |
| `background_retention_ms` | `number` | `604800000` | Retention for terminal background jobs (`succeeded`/`failed`) in milliseconds. Pending/running jobs are not deleted by this cleanup. |
| `instance_id` | `string` | generated | Database scheduler lease owner id. Leave empty to generate a unique per-process id. |

## `frontend`

| Field | Type | Default | Description |
Expand Down
28 changes: 24 additions & 4 deletions packages/homepage/src/content/docs/ko/configuration/scheduler.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,33 @@ description: Tinyauth의 데이터 정리 스케줄러 및 정리 정책 구성
# config.yaml
scheduler:
enabled: true
cron: '0 2 * * *'
mode: croner
cleanup_cron: '0 2 * * *'
# mode: database 를 사용할 때만 적용돼요.
poll_interval_ms: 5000
lock_ttl_ms: 60000
background_retry_delay_ms: 1000
background_max_attempts: 3
background_retention_ms: 604800000
instance_id: ''
```

- `scheduler.enabled`: 내장 스케줄러를 활성화할지 여부예요. 기본값은 `true`예요.
- `scheduler.cron`: 정리 작업 실행 주기예요. 표준 5필드 cron 형식(`분 시 일 월 요일`)을 사용해요. 기본값은 `0 2 * * *`(매일 오전 2시)예요.
- `scheduler.mode`: 스케줄러 백엔드예요. `croner`는 단일 프로세스용 인메모리 스케줄러이고, `database`는 DB lease를 사용해 여러 인스턴스 중 하나만 작업을 획득하도록 하는 분산 스케줄러예요. 기본값은 `croner`예요.
- `scheduler.cleanup_cron`: 내장 정리 작업 실행 주기예요. 표준 5필드 cron 형식(`분 시 일 월 요일`)을 사용해요. 기본값은 `0 2 * * *`(매일 오전 2시)예요.
- `scheduler.poll_interval_ms`: `database` 모드에서 due job을 확인하는 주기예요. 기본값은 `5000`이에요.
- `scheduler.lock_ttl_ms`: `database` 모드에서 job lease가 유효한 시간이에요. 작업이 오래 걸리면 실행 중 lease가 자동 갱신돼요. 기본값은 `60000`이에요.
- `scheduler.background_retry_delay_ms`: `database` 모드에서 실패한 background job을 다시 실행하기 전 대기 시간이에요. 기본값은 `1000`이에요.
- `scheduler.background_max_attempts`: `database` 모드에서 background job을 최종 실패로 표시하기 전 최대 시도 횟수예요. 기본값은 `3`이에요.
- `scheduler.background_retention_ms`: `database` 모드에서 완료된 background job(`succeeded`/`failed`)을 보존하는 기간(ms)이에요. 기본값은 `604800000`(7일)이에요. `pending`/`running` job은 이 정리 정책으로 삭제하지 않아요.
- `scheduler.instance_id`: `database` 모드에서 lease 소유자를 식별하는 값이에요. 비워두면 프로세스마다 고유한 값이 자동 생성돼요. 직접 지정할 경우 replica/process마다 반드시 고유해야 해요.

:::note
Kubernetes 환경에서는 내장 스케줄러 대신 CronJob을 사용하는 것이 좋아요. 이 경우 `scheduler.enabled: false`로 설정하고, 외부에서 `tinyauth cleanup` CLI 명령을 실행하세요. 자세한 내용은 [Kubernetes 배포 가이드](/ko/deployment/kubernetes)를 참고해 주세요.
Kubernetes 환경에서는 단일 실행 보장이 필요하면 `scheduler.mode: database`를 사용하거나, 내장 스케줄러 대신 CronJob을 사용하는 것이 좋아요. CronJob을 사용할 경우 `scheduler.enabled: false`로 설정하고 외부에서 `tinyauth cleanup` CLI 명령을 실행하세요. 자세한 내용은 [Kubernetes 배포 가이드](/ko/deployment/kubernetes)를 참고해 주세요.
:::

:::caution
`database` 모드에서 모든 작업은 at-least-once 방식으로 실행돼요. lease가 만료되면 다른 인스턴스가 같은 작업을 다시 획득할 수 있으므로 scheduled/background job handler는 가능하면 멱등적으로 작성하세요.
:::

---
Expand Down Expand Up @@ -128,7 +147,8 @@ account_deletion:

scheduler:
enabled: true
cron: '0 2 * * *'
mode: croner
cleanup_cron: '0 2 * * *'

cleanup:
revoked_tokens:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,14 @@ description: 전체 config.yaml 스키마 레퍼런스
| 항목 | 유형 | 기본값 | 환경 변수 | 설명 |
|------|------|--------|-----------|------|
| `enabled` | `boolean` | `true` | `SCHEDULER_ENABLED` | 내장 스케줄러 활성화 |
| `cron` | `string` | `'0 2 * * *'` | `SCHEDULER_CRON` | cron 표현식 |
| `mode` | `'croner' \| 'database'` | `'croner'` | `SCHEDULER_MODE` | 스케줄러 백엔드 |
| `cleanup_cron` | `string` | `'0 2 * * *'` | `SCHEDULER_CLEANUP_CRON` | 정리 작업 cron 표현식 |
| `poll_interval_ms` | `number` | `5000` | `SCHEDULER_POLL_INTERVAL_MS` | `database` 모드 polling 주기(ms) |
| `lock_ttl_ms` | `number` | `60000` | `SCHEDULER_LOCK_TTL_MS` | `database` 모드 lease TTL(ms) |
| `background_retry_delay_ms` | `number` | `1000` | `SCHEDULER_BACKGROUND_RETRY_DELAY_MS` | `database` 모드 background job 재시도 대기 시간(ms) |
| `background_max_attempts` | `integer` | `3` | `SCHEDULER_BACKGROUND_MAX_ATTEMPTS` | `database` 모드 background job 최대 시도 횟수 |
| `background_retention_ms` | `number` | `604800000` | `SCHEDULER_BACKGROUND_RETENTION_MS` | 완료된 background job(`succeeded`/`failed`) 보존 기간(ms). `pending`/`running` job은 삭제하지 않음 |
| `instance_id` | `string` | 자동 생성 | `SCHEDULER_INSTANCE_ID` | `database` 모드 lease 소유자 식별자 |

## `cleanup`

Expand Down
9 changes: 9 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
"types": "./dist/entrypoints/scheduler/croner.d.ts",
"default": "./dist/entrypoints/scheduler/croner.js"
},
"./scheduler/database": {
"types": "./dist/entrypoints/scheduler/database.d.ts",
"default": "./dist/entrypoints/scheduler/database.js"
},
"./identity-providers/github": {
"types": "./dist/entrypoints/identity-providers/github.d.ts",
"default": "./dist/entrypoints/identity-providers/github.js"
Expand Down Expand Up @@ -117,6 +121,11 @@
"types": "./dist/entrypoints/scheduler/croner.d.ts",
"default": "./dist/entrypoints/scheduler/croner.js"
},
"./scheduler/database": {
"@tinyauth/source": "./src/entrypoints/scheduler/database.ts",
"types": "./dist/entrypoints/scheduler/database.d.ts",
"default": "./dist/entrypoints/scheduler/database.js"
},
"./identity-providers/github": {
"@tinyauth/source": "./src/entrypoints/identity-providers/github.ts",
"types": "./dist/entrypoints/identity-providers/github.d.ts",
Expand Down
51 changes: 51 additions & 0 deletions packages/server/src/entities/background-job.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { defineEntity, type InferEntity } from '@mikro-orm/core';
import { BackgroundJobRepository } from '../repositories/background-job.repository.ts';
import { BaseSchema } from './base.entity.ts';

export type BackgroundJobStatus =
| 'pending'
| 'running'
| 'succeeded'
| 'failed';

export const BackgroundJobEntitySchema = defineEntity({
name: 'BackgroundJobEntity',
tableName: 'background_jobs',
comment: 'Durable background job queue',
extends: BaseSchema,
repository: () => BackgroundJobRepository,
properties: (p) => ({
id: p.string().primary().comment('Stable background job execution id'),
jobId: p.string().comment('Registered background job identifier'),
payload: p.text().comment('Serialized JSON job payload'),
status: p.string().comment('Background job status').default('pending'),
availableAt: p.datetime().comment('Earliest time this job can run'),
lockedBy: p
.string()
.comment('Scheduler instance holding the lease')
.nullable(),
lockedUntil: p.datetime().comment('Lease expiration timestamp').nullable(),
attemptCount: p.integer().comment('Total run attempts').default(0),
maxAttempts: p.integer().comment('Maximum run attempts').default(3),
lastError: p.text().comment('Last failure message').nullable(),
completedAt: p.datetime().comment('Completion timestamp').nullable(),
}),
indexes: [
{
name: 'background_jobs_status_available_at_idx',
properties: ['status', 'availableAt'],
},
{
name: 'background_jobs_locked_until_idx',
properties: ['lockedUntil'],
},
{
name: 'background_jobs_job_id_idx',
properties: ['jobId'],
},
],
});

export type IBackgroundJobEntity = InferEntity<
typeof BackgroundJobEntitySchema
>;
50 changes: 50 additions & 0 deletions packages/server/src/entities/scheduler-job.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { defineEntity, type InferEntity } from '@mikro-orm/core';
import { SchedulerJobRepository } from '../repositories/scheduler-job.repository.ts';
import { BaseSchema } from './base.entity.ts';

export const SchedulerJobEntitySchema = defineEntity({
name: 'SchedulerJobEntity',
tableName: 'scheduled_jobs',
comment: 'Persistent scheduler jobs and leases',
extends: BaseSchema,
repository: () => SchedulerJobRepository,
properties: (p) => ({
id: p.string().primary().comment('Stable scheduler job identifier'),
name: p.string().comment('Human-readable scheduler job name'),
enabled: p
.boolean()
.comment('Whether the scheduler job is enabled')
.default(true),
cron: p.string().comment('Cron expression for the job schedule'),
nextRunAt: p.datetime().comment('Next scheduled run timestamp').nullable(),
lastRunAt: p.datetime().comment('Last run start timestamp').nullable(),
lastSuccessAt: p
.datetime()
.comment('Last successful completion timestamp')
.nullable(),
lastErrorAt: p
.datetime()
.comment('Last failed completion timestamp')
.nullable(),
lastError: p.text().comment('Last failure message').nullable(),
lockedBy: p
.string()
.comment('Scheduler instance holding the lease')
.nullable(),
lockedUntil: p.datetime().comment('Lease expiration timestamp').nullable(),
runCount: p.integer().comment('Total run attempts').default(0),
failureCount: p.integer().comment('Total failed run attempts').default(0),
}),
indexes: [
{
name: 'scheduled_jobs_enabled_next_run_at_idx',
properties: ['enabled', 'nextRunAt'],
},
{
name: 'scheduled_jobs_locked_until_idx',
properties: ['lockedUntil'],
},
],
});

export type ISchedulerJobEntity = InferEntity<typeof SchedulerJobEntitySchema>;
12 changes: 10 additions & 2 deletions packages/server/src/entrypoints/database/migrations.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, expect, test } from 'vitest';
import { POSTGRES_MIGRATIONS } from '../../migrations/postgres/index.ts';
import { Migration20260509171036_initial as PostgresInitialMigration } from '../../migrations/postgres/Migration20260509171036_initial.ts';
import { Migration20260512120000_add_scheduler_jobs as PostgresSchedulerJobsMigration } from '../../migrations/postgres/Migration20260512120000_add_scheduler_jobs.ts';
import { SQLITE_MIGRATIONS } from '../../migrations/sqlite/index.ts';
import { Migration20260509171226_initial as SqliteInitialMigration } from '../../migrations/sqlite/Migration20260509171226_initial.ts';
import { Migration20260512120000_add_scheduler_jobs as SqliteSchedulerJobsMigration } from '../../migrations/sqlite/Migration20260512120000_add_scheduler_jobs.ts';
import { postgres } from './postgres/postgres.ts';
import { sqlite } from './sqlite/sqlite.ts';

Expand All @@ -16,7 +18,10 @@ describe('database migrations', () => {
user: 'tinyauth',
}).getMikroOrmOptions();

expect(POSTGRES_MIGRATIONS).toEqual([PostgresInitialMigration]);
expect(POSTGRES_MIGRATIONS).toEqual([
PostgresInitialMigration,
PostgresSchedulerJobsMigration,
]);
expect(options.migrations?.migrationsList).toBe(POSTGRES_MIGRATIONS);
expect(options.migrations?.path).toBeUndefined();
expect(options.migrations?.pathTs).toBeUndefined();
Expand Down Expand Up @@ -47,7 +52,10 @@ describe('database migrations', () => {
test: false,
}).getMikroOrmOptions();

expect(SQLITE_MIGRATIONS).toEqual([SqliteInitialMigration]);
expect(SQLITE_MIGRATIONS).toEqual([
SqliteInitialMigration,
SqliteSchedulerJobsMigration,
]);
expect(options.migrations?.migrationsList).toBe(SQLITE_MIGRATIONS);
expect(options.migrations?.path).toBeUndefined();
expect(options.migrations?.pathTs).toBeUndefined();
Expand Down
Loading
Loading