CREATE TABLE IF NOT EXISTS ops.scheduler_jobs ( job_key VARCHAR(96) PRIMARY KEY, display_name VARCHAR(120) NOT NULL, category VARCHAR(64) NOT NULL, description TEXT, enabled BOOLEAN NOT NULL DEFAULT true, schedule_type VARCHAR(20) NOT NULL DEFAULT 'interval', interval_seconds INT NOT NULL DEFAULT 300, cron_expr VARCHAR(120), timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai', timeout_seconds INT NOT NULL DEFAULT 30, batch_size INT, max_concurrency INT NOT NULL DEFAULT 1, config JSONB NOT NULL DEFAULT '{}'::jsonb, version INT NOT NULL DEFAULT 1, updated_by BIGINT, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CHECK (job_key ~ '^[a-z0-9_:-]+$'), CHECK (schedule_type IN ('interval', 'cron', 'manual')), CHECK (interval_seconds > 0), CHECK (timeout_seconds > 0), CHECK (max_concurrency > 0) ); CREATE INDEX IF NOT EXISTS idx_scheduler_jobs_category ON ops.scheduler_jobs (category, job_key); CREATE TABLE IF NOT EXISTS ops.scheduler_instances ( instance_id VARCHAR(160) PRIMARY KEY, hostname VARCHAR(255) NOT NULL, process VARCHAR(80) NOT NULL, build_version VARCHAR(120), started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), metadata JSONB NOT NULL DEFAULT '{}'::jsonb ); CREATE INDEX IF NOT EXISTS idx_scheduler_instances_seen ON ops.scheduler_instances (last_seen_at DESC); CREATE TABLE IF NOT EXISTS ops.scheduler_job_runs ( id BIGSERIAL PRIMARY KEY, job_key VARCHAR(96) NOT NULL REFERENCES ops.scheduler_jobs(job_key), trigger_id BIGINT, scheduler_instance_id VARCHAR(160), trigger_type VARCHAR(30) NOT NULL DEFAULT 'auto', status VARCHAR(30) NOT NULL DEFAULT 'running', config_version INT NOT NULL DEFAULT 1, config_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, stats JSONB NOT NULL DEFAULT '{}'::jsonb, error_message TEXT, started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), finished_at TIMESTAMPTZ, duration_ms BIGINT, CHECK (trigger_type IN ('auto', 'manual', 'dry_run')), CHECK (status IN ('running', 'success', 'failed', 'skipped')) ); CREATE INDEX IF NOT EXISTS idx_scheduler_job_runs_job_started ON ops.scheduler_job_runs (job_key, started_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_scheduler_job_runs_status_started ON ops.scheduler_job_runs (status, started_at DESC); CREATE INDEX IF NOT EXISTS idx_scheduler_job_runs_auto_started ON ops.scheduler_job_runs (job_key, started_at DESC, id DESC) WHERE trigger_type = 'auto'; CREATE TABLE IF NOT EXISTS ops.scheduler_job_triggers ( id BIGSERIAL PRIMARY KEY, job_key VARCHAR(96) NOT NULL REFERENCES ops.scheduler_jobs(job_key), trigger_type VARCHAR(30) NOT NULL DEFAULT 'manual', status VARCHAR(30) NOT NULL DEFAULT 'pending', requested_by BIGINT, reason TEXT, config_override JSONB NOT NULL DEFAULT '{}'::jsonb, scheduler_instance_id VARCHAR(160), run_id BIGINT REFERENCES ops.scheduler_job_runs(id), requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), claimed_at TIMESTAMPTZ, finished_at TIMESTAMPTZ, error_message TEXT, CHECK (trigger_type IN ('manual', 'dry_run')), CHECK (status IN ('pending', 'claimed', 'success', 'failed', 'skipped')) ); CREATE INDEX IF NOT EXISTS idx_scheduler_job_triggers_pending ON ops.scheduler_job_triggers (job_key, requested_at ASC, id ASC) WHERE status = 'pending'; INSERT INTO ops.scheduler_jobs (job_key, display_name, category, description, enabled, schedule_type, interval_seconds, timezone, timeout_seconds, batch_size, config) VALUES ('schedule_dispatch', '内容定时任务派发', 'content', '索引驱动的延迟队列派发,仅认领到期内容任务并投递生成队列。', true, 'interval', 15, 'Asia/Shanghai', 30, 100, '{}'::jsonb), ('monitoring_daily_task', 'AI 监控每日任务生成', 'monitoring', '按业务日小批量物化监控采集任务并派发到桌面任务队列。', true, 'interval', 300, 'Asia/Shanghai', 45, 64, '{"max_materialize_per_plan":12,"max_materialize_per_brand":4}'::jsonb), ('monitoring_result_recovery', '监控结果恢复', 'monitoring', '按回调结果队列索引恢复未成功入队的监控结果。', true, 'interval', 60, 'Asia/Shanghai', 30, 100, '{}'::jsonb), ('monitoring_lease_recovery', '监控租约回收', 'monitoring', '按过期租约索引限批回收超过租约时间仍未回调的监控任务。', true, 'interval', 1800, 'Asia/Shanghai', 30, 1000, '{}'::jsonb), ('monitoring_received_inspection', '监控 received 卡死检查', 'monitoring', '按 received watchdog 索引限批标记长时间未入队的监控任务。', true, 'interval', 300, 'Asia/Shanghai', 30, 100, '{"threshold":"15m"}'::jsonb), ('generation_state_check', '生成任务状态巡检', 'generation', '低频、限批检查文章生成任务与文章状态一致性。', true, 'interval', 300, 'Asia/Shanghai', 10, 100, '{"lookback":"24h"}'::jsonb), ('monitoring_retention_cleanup', '监控历史保留清理', 'monitoring', '按保留窗口和索引限批删除最近 30 天以前的监控历史数据。', true, 'interval', 86400, 'Asia/Shanghai', 900, 5000, '{"retention_days":30,"batch_size":5000,"max_batches_per_run":200,"dry_run":false,"statement_timeout":"5s","run_at_local":"02:30"}'::jsonb) ON CONFLICT (job_key) DO UPDATE SET display_name = EXCLUDED.display_name, category = EXCLUDED.category, description = EXCLUDED.description, config = ops.scheduler_jobs.config || EXCLUDED.config, updated_at = NOW();