Loading...
POST /taskstarget_type (e.g., HTTP_CALLBACK, QUEUE_MESSAGE)target_config (e.g., URL, headers/body, queue name, payload)run_at (ISO timestamp, UTC)idempotency_key (optional string)task_id, status, run_atPOST /recurring-tasksnametarget_typetarget_configstart_time (UTC)interval_seconds (simple fixed interval as per transcript, not full cron)max_runs (optional)idempotency_key (optional)recurring_task_id, next_run_at, statusGET /tasks/{task_id}task_id, status (PENDING, RUNNING, SUCCESS, FAILED, CANCELLED)run_at, started_at, completed_at, last_error, attemptsPOST /tasks/{task_id}/canceltask_id, statusPOST /recurring-tasks/{id}/pausePOST /recurring-tasks/{id}/resumeGET /tasks?status=PENDING&limit=100&cursor=...POST /tasks/{task_id}/dependenciesGET /tasks/{task_id}/dependenciesPOST /notification-settingsuser_id, on_success (bool), on_failure (bool), delivery_type (EMAIL, SLACK_WEBHOOK, HTTP_WEBHOOK), delivery_config (json).GET /healthGET /metrics (Prometheus format), exposing:POST /tasks/{task_id}/retryPOST /tasks/{task_id}/force-completeGET /scheduler/status (e.g., shard ownership, last DB scan time).Possible future endpoints
GET /recurring-tasks/{id} and /recurring-tasks for management dashboards.POST /recurring-tasks/{id}/trigger-now to force a run.We need two different storage patterns:
run_at).We’ll use Postgres as the main durable store, and Redis for a near-future task queue / scheduling wheel.
Table: tasks (one-time or instances of recurring tasks)
id (UUID, PK)recurring_task_id (nullable, FK to recurring_tasks)status (enum: PENDING, RUNNING, SUCCESS, FAILED, CANCELLED)run_at (timestamp with time zone, indexed)created_at (timestamp)updated_at (timestamp)target_type (varchar)target_config (jsonb) – HTTP URL, headers, payload, etc.attempts (int)max_attempts (int)last_error (text, nullable)idempotency_key (varchar, nullable, indexed)dedupe_key (varchar, nullable) – for exactly-once semantics if neededpriority (int, default 0)time_zone (varchar, optional, if we decide to support it later, but for now we store UTC and keep tz on the client side)shard_key (int or varchar) – to assign tasks to a scheduler shard deterministically.Useful indexes
(status, run_at) to find due tasks efficiently: status = 'PENDING' AND run_at <= now().recurring_task_id to query all instances of a recurring schedule.(idempotency_key) to prevent duplicate creation.btree index on (status, run_at, priority) if we introduce priority scheduling.Table: task_dependencies (for DAG)
task_id (UUID, FK to tasks)depends_on_task_id (UUID, FK to tasks)(task_id, depends_on_task_id)dependency_type (e.g., MUST_SUCCEED, MUST_FINISH).To find tasks that are ready to run we’ll either:
ready flag / unresolved_dependencies_count column on tasks, ortask_dependencies (but that’s slower).Table: recurring_tasks
id (UUID, PK)name (varchar)status (enum: ACTIVE, PAUSED, CANCELLED)start_time (timestamp with time zone)interval_seconds (int) – simple fixed interval as per transcript hint.next_run_at (timestamp with time zone, indexed)max_runs (int, nullable)runs_count (int)target_type (varchar)target_config (jsonb)created_at (timestamp)updated_at (timestamp)Table: task_execution_logs (optional for history/observability)
id (bigserial, PK)task_id (UUID, FK to tasks)status (enum: SUCCESS, FAILED)attempt_number (int)started_at (timestamp)completed_at (timestamp)error_message (text, nullable)Redis keys
due_tasks with score = run_at_epoch_ms, member = task_id for tasks due in the next N minutes.due_tasks sorted set.tasks/mutate, recurring catch-up config, and backoff configuration.create:idempotency_key to avoid duplicates.reschedule:run_at, next_attempt_at, and reinsert into Redis/priority queue.status = PENDING and run_at in the near future (e.g., next 5 minutes) and loads them into Redis sorted set.recurring_tasks where status = ACTIVE and next_run_at <= now().tasks rows and updates next_run_at = next_run_at + interval_seconds.now() with last_run_at.catch_up_mode:RUN_ALL_MISSED: compute number of missed intervals; create that many tasks (with run_at spaced at the interval).RUN_ONE_NOW: create one task with run_at = now(), then set next_run_at = now() + interval.SKIP_MISSED: set next_run_at to the next future aligned time; maybe log skipped count.last_run_at and next_run_at atomically in a transaction.priority first, then run_at / next_attempt_at.priority and due_time.status = PENDING, unresolved_dependencies_count = 0, and run_at or next_attempt_at <= now() are eligible.run_at <= now() + small_delta.SELECT ... FOR UPDATE or status = PENDING AND ... update with rows affected = 1).run_at with high-resolution timers.max_attempts.attempts + 1 >= max_attempts → mark FAILED terminally.delay:FIXED: backoff_base_delay_msEXPONENTIAL: min(backoff_base_delay_ms * 2^(attempts), backoff_max_delay_ms)next_attempt_at = now() + delay.status back to PENDING.unresolved_dependencies_count.heartbeat/locked_by column and consider tasks stuck in RUNNING beyond a threshold as re‑queue candidates.Interactions (rough flow)
Key challenges
SELECT ... WHERE run_at <= now()), we’d hit the DB a lot and risk delays.UPDATE tasks SET status='RUNNING' WHERE id=? AND status='PENDING'.interval_seconds, no complicated cron, no time zones, no calendar holidays.recurring_task with next_run_at <= now():tasks with run_at = next_run_at.next_run_at = next_run_at + interval_seconds.runs_count; if runs_count >= max_runs, set status = CANCELLED.next_run_at, not now()).hash(recurring_task_id) % N and assign shards to instances.tasks, while Redis holds the multiple due tasks, each dispatcher still performs an atomic DB claim, so duplicates from Redis don’t lead to double execution.tasks table at lower frequency; performance degrades but correctness remains.Key Points:
interval_seconds with UTC timestamps.task_execution_logs and metrics.next_attempt_at based on backoff.delay = random_between(0.5, 1.5) * min(base * 2^attempts, max_delay)dedupe_key (maybe same as idempotency_key).dedupe_key and attempt_number.(task_id, attempt_number) or (dedupe_key, final_state).unresolved_dependencies_count = 0 are eligible.unresolved_dependencies_count remains > 0, so it’s never considered due even if its run_at passes.