|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { ObjectSchema, Field } from '@objectstack/spec/data'; |
| 4 | + |
| 5 | +/** |
| 6 | + * sys_job_queue — Durable Background Job / Message Queue + DLQ |
| 7 | + * |
| 8 | + * The persistent backing store for `DbQueueAdapter`. Each row is one |
| 9 | + * enqueued message — pending until a worker leases (`status='running'`), |
| 10 | + * then `completed`, `failed`, or finally `dlq` after exhausting retries. |
| 11 | + * |
| 12 | + * DLQ rows are simply `status='dlq'` rows in this same table; no separate |
| 13 | + * table needed. Listing/replay is a filter on `status`. |
| 14 | + * |
| 15 | + * Idempotency: `idempotency_key` + `queue` are deduplicated by the adapter |
| 16 | + * over a configurable window (default 24h) — the column is indexed. |
| 17 | + * |
| 18 | + * Concurrency: workers claim a row by CAS-updating `status` from `pending` |
| 19 | + * to `running` plus setting `locked_by`/`locked_until`. Reads only return |
| 20 | + * rows whose lock has not been claimed (or whose lease expired). |
| 21 | + * |
| 22 | + * Writers: `DbQueueAdapter` (publish/lease/complete/fail). |
| 23 | + * Readers: Studio DLQ view, ops dashboards, the adapter's worker loop. |
| 24 | + * |
| 25 | + * @namespace sys |
| 26 | + */ |
| 27 | +export const SysJobQueue = ObjectSchema.create({ |
| 28 | + name: 'sys_job_queue', |
| 29 | + label: 'Job Queue Message', |
| 30 | + pluralLabel: 'Job Queue Messages', |
| 31 | + icon: 'inbox', |
| 32 | + isSystem: true, |
| 33 | + managedBy: 'system', |
| 34 | + description: 'Durable job/message queue including dead letters', |
| 35 | + displayNameField: 'queue', |
| 36 | + titleFormat: '{queue} #{id}', |
| 37 | + compactLayout: ['queue', 'status', 'attempts', 'scheduled_for', 'last_error'], |
| 38 | + |
| 39 | + fields: { |
| 40 | + id: Field.text({ label: 'Message ID', required: true, readonly: true, group: 'System' }), |
| 41 | + |
| 42 | + queue: Field.text({ |
| 43 | + label: 'Queue', |
| 44 | + required: true, |
| 45 | + maxLength: 255, |
| 46 | + searchable: true, |
| 47 | + description: 'Logical queue name (snake_case)', |
| 48 | + group: 'Identity', |
| 49 | + }), |
| 50 | + |
| 51 | + idempotency_key: Field.text({ |
| 52 | + label: 'Idempotency Key', |
| 53 | + required: false, |
| 54 | + maxLength: 255, |
| 55 | + description: 'Deduplication key within (queue, window)', |
| 56 | + group: 'Identity', |
| 57 | + }), |
| 58 | + |
| 59 | + payload_json: Field.textarea({ |
| 60 | + label: 'Payload (JSON)', |
| 61 | + required: false, |
| 62 | + description: 'Serialized message body', |
| 63 | + group: 'Content', |
| 64 | + }), |
| 65 | + |
| 66 | + metadata_json: Field.textarea({ |
| 67 | + label: 'Metadata (JSON)', |
| 68 | + required: false, |
| 69 | + description: 'Serialized metadata bag (tenant_id, source_record, ...)', |
| 70 | + group: 'Content', |
| 71 | + }), |
| 72 | + |
| 73 | + status: Field.select( |
| 74 | + ['pending', 'running', 'completed', 'failed', 'dlq'], |
| 75 | + { |
| 76 | + label: 'Status', |
| 77 | + required: true, |
| 78 | + defaultValue: 'pending', |
| 79 | + description: 'Lifecycle state', |
| 80 | + group: 'State', |
| 81 | + }, |
| 82 | + ), |
| 83 | + |
| 84 | + priority: Field.number({ |
| 85 | + label: 'Priority', |
| 86 | + required: false, |
| 87 | + defaultValue: 100, |
| 88 | + description: 'Lower = higher priority', |
| 89 | + group: 'Schedule', |
| 90 | + }), |
| 91 | + |
| 92 | + attempts: Field.number({ label: 'Attempts', required: false, defaultValue: 0, group: 'State' }), |
| 93 | + max_attempts: Field.number({ label: 'Max Attempts', required: false, defaultValue: 3, group: 'State' }), |
| 94 | + |
| 95 | + backoff_type: Field.select( |
| 96 | + ['fixed', 'exponential'], |
| 97 | + { label: 'Backoff', required: false, defaultValue: 'exponential', group: 'Schedule' }, |
| 98 | + ), |
| 99 | + backoff_delay_ms: Field.number({ |
| 100 | + label: 'Backoff Base (ms)', |
| 101 | + required: false, |
| 102 | + defaultValue: 1000, |
| 103 | + group: 'Schedule', |
| 104 | + }), |
| 105 | + backoff_max_delay_ms: Field.number({ |
| 106 | + label: 'Backoff Cap (ms)', |
| 107 | + required: false, |
| 108 | + group: 'Schedule', |
| 109 | + }), |
| 110 | + |
| 111 | + scheduled_for: Field.datetime({ |
| 112 | + label: 'Scheduled For', |
| 113 | + required: false, |
| 114 | + description: 'Earliest time a worker may lease this message', |
| 115 | + group: 'Schedule', |
| 116 | + }), |
| 117 | + |
| 118 | + locked_by: Field.text({ |
| 119 | + label: 'Locked By', |
| 120 | + required: false, |
| 121 | + maxLength: 255, |
| 122 | + description: 'Worker id holding the lease', |
| 123 | + group: 'Lease', |
| 124 | + }), |
| 125 | + locked_until: Field.datetime({ |
| 126 | + label: 'Locked Until', |
| 127 | + required: false, |
| 128 | + description: 'Lease expiry; if past, another worker may claim', |
| 129 | + group: 'Lease', |
| 130 | + }), |
| 131 | + |
| 132 | + last_error: Field.textarea({ label: 'Last Error', required: false, group: 'State' }), |
| 133 | + completed_at: Field.datetime({ label: 'Completed At', required: false, group: 'State' }), |
| 134 | + |
| 135 | + created_at: Field.datetime({ |
| 136 | + label: 'Created At', |
| 137 | + required: true, |
| 138 | + defaultValue: 'NOW()', |
| 139 | + readonly: true, |
| 140 | + group: 'System', |
| 141 | + }), |
| 142 | + updated_at: Field.datetime({ label: 'Updated At', required: false, group: 'System' }), |
| 143 | + }, |
| 144 | + |
| 145 | + indexes: [ |
| 146 | + { fields: ['queue', 'status', 'scheduled_for'] }, |
| 147 | + { fields: ['idempotency_key', 'queue'] }, |
| 148 | + { fields: ['status'] }, |
| 149 | + ], |
| 150 | +}); |
0 commit comments