@nicnocquee/dataqueue 1.30.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,9 +9,56 @@ import {
9
9
  TagQueryMode,
10
10
  JobType,
11
11
  RedisJobQueueConfig,
12
+ CronScheduleRecord,
13
+ CronScheduleStatus,
14
+ EditCronScheduleOptions,
15
+ WaitpointRecord,
16
+ CreateTokenOptions,
12
17
  } from '../types.js';
13
- import { QueueBackend, JobFilters, JobUpdates } from '../backend.js';
18
+ import {
19
+ QueueBackend,
20
+ JobFilters,
21
+ JobUpdates,
22
+ CronScheduleInput,
23
+ } from '../backend.js';
14
24
  import { log } from '../log-context.js';
25
+
26
+ const MAX_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000;
27
+
28
+ /** Parse a timeout string like '10m', '1h', '24h', '7d' into milliseconds. */
29
+ function parseTimeoutString(timeout: string): number {
30
+ const match = timeout.match(/^(\d+)(s|m|h|d)$/);
31
+ if (!match) {
32
+ throw new Error(
33
+ `Invalid timeout format: "${timeout}". Expected format like "10m", "1h", "24h", "7d".`,
34
+ );
35
+ }
36
+ const value = parseInt(match[1], 10);
37
+ const unit = match[2];
38
+ let ms: number;
39
+ switch (unit) {
40
+ case 's':
41
+ ms = value * 1000;
42
+ break;
43
+ case 'm':
44
+ ms = value * 60 * 1000;
45
+ break;
46
+ case 'h':
47
+ ms = value * 60 * 60 * 1000;
48
+ break;
49
+ case 'd':
50
+ ms = value * 24 * 60 * 60 * 1000;
51
+ break;
52
+ default:
53
+ throw new Error(`Unknown timeout unit: "${unit}"`);
54
+ }
55
+ if (!Number.isFinite(ms) || ms > MAX_TIMEOUT_MS) {
56
+ throw new Error(
57
+ `Timeout value "${timeout}" is too large. Maximum allowed is 365 days.`,
58
+ );
59
+ }
60
+ return ms;
61
+ }
15
62
  import {
16
63
  ADD_JOB_SCRIPT,
17
64
  GET_NEXT_BATCH_SCRIPT,
@@ -21,8 +68,12 @@ import {
21
68
  CANCEL_JOB_SCRIPT,
22
69
  PROLONG_JOB_SCRIPT,
23
70
  RECLAIM_STUCK_JOBS_SCRIPT,
24
- CLEANUP_OLD_JOBS_SCRIPT,
71
+ CLEANUP_OLD_JOBS_BATCH_SCRIPT,
72
+ WAIT_JOB_SCRIPT,
73
+ COMPLETE_WAITPOINT_SCRIPT,
74
+ EXPIRE_TIMED_OUT_WAITPOINTS_SCRIPT,
25
75
  } from './redis-scripts.js';
76
+ import { randomUUID } from 'crypto';
26
77
 
27
78
  /** Helper: convert a Redis hash flat array [k,v,k,v,...] to a JS object */
28
79
  function hashToObject(arr: string[]): Record<string, string> {
@@ -108,9 +159,24 @@ function deserializeJob<PayloadMap, T extends JobType<PayloadMap>>(
108
159
  tags,
109
160
  idempotencyKey: nullish(h.idempotencyKey) as string | null | undefined,
110
161
  progress: numOrNull(h.progress),
162
+ waitUntil: dateOrNull(h.waitUntil),
163
+ waitTokenId: nullish(h.waitTokenId) as string | null | undefined,
164
+ stepData: parseStepData(h.stepData),
111
165
  };
112
166
  }
113
167
 
168
+ /** Parse step data from a Redis hash field. */
169
+ function parseStepData(
170
+ raw: string | undefined,
171
+ ): Record<string, any> | undefined {
172
+ if (!raw || raw === 'null') return undefined;
173
+ try {
174
+ return JSON.parse(raw);
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
179
+
114
180
  export class RedisBackend implements QueueBackend {
115
181
  private client: RedisType;
116
182
  private prefix: string;
@@ -314,10 +380,19 @@ export class RedisBackend implements QueueBackend {
314
380
  if (filters.runAt) {
315
381
  jobs = this.filterByRunAt(jobs, filters.runAt);
316
382
  }
383
+ // Cursor-based (keyset) pagination: only return jobs with id < cursor
384
+ if (filters.cursor !== undefined) {
385
+ jobs = jobs.filter((j) => j.id < filters.cursor!);
386
+ }
317
387
  }
318
388
 
319
- // Sort by createdAt DESC
320
- jobs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
389
+ // Sort by id DESC for consistent keyset pagination (matches Postgres ORDER BY id DESC)
390
+ jobs.sort((a, b) => b.id - a.id);
391
+
392
+ // When using cursor, skip offset
393
+ if (filters?.cursor !== undefined) {
394
+ return jobs.slice(0, limit);
395
+ }
321
396
  return jobs.slice(offset, offset + limit);
322
397
  }
323
398
 
@@ -616,27 +691,115 @@ export class RedisBackend implements QueueBackend {
616
691
  return count;
617
692
  }
618
693
 
619
- async cleanupOldJobs(daysToKeep = 30): Promise<number> {
694
+ /**
695
+ * Delete completed jobs older than the given number of days.
696
+ * Uses SSCAN to iterate the completed set in batches, avoiding
697
+ * loading all IDs into memory and preventing long Redis blocks.
698
+ *
699
+ * @param daysToKeep - Number of days to retain completed jobs (default 30).
700
+ * @param batchSize - Number of IDs to scan per SSCAN iteration (default 200).
701
+ * @returns Total number of deleted jobs.
702
+ */
703
+ async cleanupOldJobs(daysToKeep = 30, batchSize = 200): Promise<number> {
620
704
  const cutoffMs = this.nowMs() - daysToKeep * 24 * 60 * 60 * 1000;
621
- const result = (await this.client.eval(
622
- CLEANUP_OLD_JOBS_SCRIPT,
623
- 1,
624
- this.prefix,
625
- cutoffMs,
626
- )) as number;
627
- log(`Deleted ${result} old jobs`);
628
- return Number(result);
705
+ const setKey = `${this.prefix}status:completed`;
706
+ let totalDeleted = 0;
707
+ let cursor = '0';
708
+
709
+ do {
710
+ const [nextCursor, ids] = await this.client.sscan(
711
+ setKey,
712
+ cursor,
713
+ 'COUNT',
714
+ batchSize,
715
+ );
716
+ cursor = nextCursor;
717
+
718
+ if (ids.length > 0) {
719
+ const result = (await this.client.eval(
720
+ CLEANUP_OLD_JOBS_BATCH_SCRIPT,
721
+ 1,
722
+ this.prefix,
723
+ cutoffMs,
724
+ ...ids,
725
+ )) as number;
726
+ totalDeleted += Number(result);
727
+ }
728
+ } while (cursor !== '0');
729
+
730
+ log(`Deleted ${totalDeleted} old jobs`);
731
+ return totalDeleted;
629
732
  }
630
733
 
631
- async cleanupOldJobEvents(daysToKeep = 30): Promise<number> {
632
- // Redis events are stored per-job; cleaning up old events requires
633
- // iterating event lists and filtering by date. For now, we skip
634
- // events belonging to jobs that have been cleaned up (their keys are gone).
635
- // A full implementation would iterate all events:* keys.
636
- log(
637
- `cleanupOldJobEvents is a no-op for Redis backend (events are cleaned up with their jobs)`,
638
- );
639
- return 0;
734
+ /**
735
+ * Delete job events older than the given number of days.
736
+ * Iterates all event lists and removes events whose createdAt is before the cutoff.
737
+ * Also removes orphaned event lists (where the job no longer exists).
738
+ *
739
+ * @param daysToKeep - Number of days to retain events (default 30).
740
+ * @param batchSize - Number of event keys to scan per SCAN iteration (default 200).
741
+ * @returns Total number of deleted events.
742
+ */
743
+ async cleanupOldJobEvents(daysToKeep = 30, batchSize = 200): Promise<number> {
744
+ const cutoffMs = this.nowMs() - daysToKeep * 24 * 60 * 60 * 1000;
745
+ const pattern = `${this.prefix}events:*`;
746
+ let totalDeleted = 0;
747
+ let cursor = '0';
748
+
749
+ do {
750
+ const [nextCursor, keys] = await this.client.scan(
751
+ cursor,
752
+ 'MATCH',
753
+ pattern,
754
+ 'COUNT',
755
+ batchSize,
756
+ );
757
+ cursor = nextCursor;
758
+
759
+ for (const key of keys) {
760
+ // Check if the job still exists; if not, delete the entire event list
761
+ const jobIdStr = key.slice(`${this.prefix}events:`.length);
762
+ const jobExists = await this.client.exists(
763
+ `${this.prefix}job:${jobIdStr}`,
764
+ );
765
+ if (!jobExists) {
766
+ const len = await this.client.llen(key);
767
+ await this.client.del(key);
768
+ totalDeleted += len;
769
+ continue;
770
+ }
771
+
772
+ // Filter events by date: read all, keep recent, rewrite
773
+ const events = await this.client.lrange(key, 0, -1);
774
+ const kept: string[] = [];
775
+ for (const raw of events) {
776
+ try {
777
+ const e = JSON.parse(raw);
778
+ if (e.createdAt >= cutoffMs) {
779
+ kept.push(raw);
780
+ } else {
781
+ totalDeleted++;
782
+ }
783
+ } catch {
784
+ totalDeleted++;
785
+ }
786
+ }
787
+
788
+ if (kept.length === 0) {
789
+ await this.client.del(key);
790
+ } else if (kept.length < events.length) {
791
+ const pipeline = this.client.pipeline();
792
+ pipeline.del(key);
793
+ for (const raw of kept) {
794
+ pipeline.rpush(key, raw);
795
+ }
796
+ await pipeline.exec();
797
+ }
798
+ }
799
+ } while (cursor !== '0');
800
+
801
+ log(`Deleted ${totalDeleted} old job events`);
802
+ return totalDeleted;
640
803
  }
641
804
 
642
805
  async reclaimStuckJobs(maxProcessingTimeMinutes = 10): Promise<number> {
@@ -653,6 +816,230 @@ export class RedisBackend implements QueueBackend {
653
816
  return Number(result);
654
817
  }
655
818
 
819
+ // ── Wait / step-data support ────────────────────────────────────────
820
+
821
+ /**
822
+ * Transition a job from 'processing' to 'waiting' status.
823
+ * Persists step data so the handler can resume from where it left off.
824
+ *
825
+ * @param jobId - The job to pause.
826
+ * @param options - Wait configuration including optional waitUntil date, token ID, and step data.
827
+ */
828
+ async waitJob(
829
+ jobId: number,
830
+ options: {
831
+ waitUntil?: Date;
832
+ waitTokenId?: string;
833
+ stepData: Record<string, any>;
834
+ },
835
+ ): Promise<void> {
836
+ const now = this.nowMs();
837
+ const waitUntilMs = options.waitUntil
838
+ ? options.waitUntil.getTime().toString()
839
+ : 'null';
840
+ const waitTokenId = options.waitTokenId ?? 'null';
841
+ const stepDataJson = JSON.stringify(options.stepData);
842
+
843
+ const result = await this.client.eval(
844
+ WAIT_JOB_SCRIPT,
845
+ 1,
846
+ this.prefix,
847
+ jobId,
848
+ waitUntilMs,
849
+ waitTokenId,
850
+ stepDataJson,
851
+ now,
852
+ );
853
+
854
+ if (Number(result) === 0) {
855
+ log(
856
+ `Job ${jobId} could not be set to waiting (may have been reclaimed or is no longer processing)`,
857
+ );
858
+ return;
859
+ }
860
+
861
+ await this.recordJobEvent(jobId, JobEventType.Waiting, {
862
+ waitUntil: options.waitUntil?.toISOString() ?? null,
863
+ waitTokenId: options.waitTokenId ?? null,
864
+ });
865
+ log(`Job ${jobId} set to waiting`);
866
+ }
867
+
868
+ /**
869
+ * Persist step data for a job. Called after each ctx.run() step completes.
870
+ * Best-effort: does not throw to avoid killing the running handler.
871
+ *
872
+ * @param jobId - The job to update.
873
+ * @param stepData - The step data to persist.
874
+ */
875
+ async updateStepData(
876
+ jobId: number,
877
+ stepData: Record<string, any>,
878
+ ): Promise<void> {
879
+ try {
880
+ const now = this.nowMs();
881
+ await this.client.hset(
882
+ `${this.prefix}job:${jobId}`,
883
+ 'stepData',
884
+ JSON.stringify(stepData),
885
+ 'updatedAt',
886
+ now.toString(),
887
+ );
888
+ } catch (error) {
889
+ log(`Error updating stepData for job ${jobId}: ${error}`);
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Create a waitpoint token.
895
+ *
896
+ * @param jobId - The job ID to associate with the token (null if created outside a handler).
897
+ * @param options - Optional timeout string (e.g. '10m', '1h') and tags.
898
+ * @returns The created waitpoint with its unique ID.
899
+ */
900
+ async createWaitpoint(
901
+ jobId: number | null,
902
+ options?: CreateTokenOptions,
903
+ ): Promise<{ id: string }> {
904
+ const id = `wp_${randomUUID()}`;
905
+ const now = this.nowMs();
906
+ let timeoutAt: number | null = null;
907
+
908
+ if (options?.timeout) {
909
+ const ms = parseTimeoutString(options.timeout);
910
+ timeoutAt = now + ms;
911
+ }
912
+
913
+ const key = `${this.prefix}waitpoint:${id}`;
914
+ const fields: string[] = [
915
+ 'id',
916
+ id,
917
+ 'jobId',
918
+ jobId !== null ? jobId.toString() : 'null',
919
+ 'status',
920
+ 'waiting',
921
+ 'output',
922
+ 'null',
923
+ 'timeoutAt',
924
+ timeoutAt !== null ? timeoutAt.toString() : 'null',
925
+ 'createdAt',
926
+ now.toString(),
927
+ 'completedAt',
928
+ 'null',
929
+ 'tags',
930
+ options?.tags ? JSON.stringify(options.tags) : 'null',
931
+ ];
932
+
933
+ await (this.client as any).hmset(key, ...fields);
934
+
935
+ if (timeoutAt !== null) {
936
+ await this.client.zadd(`${this.prefix}waitpoint_timeout`, timeoutAt, id);
937
+ }
938
+
939
+ log(`Created waitpoint ${id} for job ${jobId}`);
940
+ return { id };
941
+ }
942
+
943
+ /**
944
+ * Complete a waitpoint token and move the associated job back to 'pending'.
945
+ *
946
+ * @param tokenId - The waitpoint token ID to complete.
947
+ * @param data - Optional data to pass to the waiting handler.
948
+ */
949
+ async completeWaitpoint(tokenId: string, data?: any): Promise<void> {
950
+ const now = this.nowMs();
951
+ const outputJson = data != null ? JSON.stringify(data) : 'null';
952
+
953
+ const result = await this.client.eval(
954
+ COMPLETE_WAITPOINT_SCRIPT,
955
+ 1,
956
+ this.prefix,
957
+ tokenId,
958
+ outputJson,
959
+ now,
960
+ );
961
+
962
+ if (Number(result) === 0) {
963
+ log(`Waitpoint ${tokenId} not found or already completed`);
964
+ return;
965
+ }
966
+
967
+ log(`Completed waitpoint ${tokenId}`);
968
+ }
969
+
970
+ /**
971
+ * Retrieve a waitpoint token by its ID.
972
+ *
973
+ * @param tokenId - The waitpoint token ID to look up.
974
+ * @returns The waitpoint record, or null if not found.
975
+ */
976
+ async getWaitpoint(tokenId: string): Promise<WaitpointRecord | null> {
977
+ const data = await this.client.hgetall(
978
+ `${this.prefix}waitpoint:${tokenId}`,
979
+ );
980
+ if (!data || Object.keys(data).length === 0) return null;
981
+
982
+ const nullish = (v: string | undefined) =>
983
+ v === undefined || v === 'null' || v === '' ? null : v;
984
+ const numOrNull = (v: string | undefined): number | null => {
985
+ const n = nullish(v);
986
+ return n === null ? null : Number(n);
987
+ };
988
+ const dateOrNull = (v: string | undefined): Date | null => {
989
+ const n = numOrNull(v);
990
+ return n === null ? null : new Date(n);
991
+ };
992
+
993
+ let output: any = null;
994
+ if (data.output && data.output !== 'null') {
995
+ try {
996
+ output = JSON.parse(data.output);
997
+ } catch {
998
+ output = data.output;
999
+ }
1000
+ }
1001
+
1002
+ let tags: string[] | null = null;
1003
+ if (data.tags && data.tags !== 'null') {
1004
+ try {
1005
+ tags = JSON.parse(data.tags);
1006
+ } catch {
1007
+ /* ignore */
1008
+ }
1009
+ }
1010
+
1011
+ return {
1012
+ id: data.id,
1013
+ jobId: numOrNull(data.jobId),
1014
+ status: data.status as WaitpointRecord['status'],
1015
+ output,
1016
+ timeoutAt: dateOrNull(data.timeoutAt),
1017
+ createdAt: new Date(Number(data.createdAt)),
1018
+ completedAt: dateOrNull(data.completedAt),
1019
+ tags,
1020
+ };
1021
+ }
1022
+
1023
+ /**
1024
+ * Expire timed-out waitpoint tokens and move their associated jobs back to 'pending'.
1025
+ *
1026
+ * @returns The number of tokens that were expired.
1027
+ */
1028
+ async expireTimedOutWaitpoints(): Promise<number> {
1029
+ const now = this.nowMs();
1030
+ const result = (await this.client.eval(
1031
+ EXPIRE_TIMED_OUT_WAITPOINTS_SCRIPT,
1032
+ 1,
1033
+ this.prefix,
1034
+ now,
1035
+ )) as number;
1036
+ const count = Number(result);
1037
+ if (count > 0) {
1038
+ log(`Expired ${count} timed-out waitpoints`);
1039
+ }
1040
+ return count;
1041
+ }
1042
+
656
1043
  // ── Internal helpers ──────────────────────────────────────────────────
657
1044
 
658
1045
  async setPendingReasonForUnpickedJobs(
@@ -795,6 +1182,386 @@ export class RedisBackend implements QueueBackend {
795
1182
  });
796
1183
  }
797
1184
 
1185
+ // ── Cron schedules ──────────────────────────────────────────────────
1186
+
1187
+ /** Create a cron schedule and return its ID. */
1188
+ async addCronSchedule(input: CronScheduleInput): Promise<number> {
1189
+ const existingId = await this.client.get(
1190
+ `${this.prefix}cron_name:${input.scheduleName}`,
1191
+ );
1192
+ if (existingId !== null) {
1193
+ throw new Error(
1194
+ `Cron schedule with name "${input.scheduleName}" already exists`,
1195
+ );
1196
+ }
1197
+
1198
+ const id = await this.client.incr(`${this.prefix}cron_id_seq`);
1199
+ const now = this.nowMs();
1200
+ const key = `${this.prefix}cron:${id}`;
1201
+
1202
+ const fields: string[] = [
1203
+ 'id',
1204
+ id.toString(),
1205
+ 'scheduleName',
1206
+ input.scheduleName,
1207
+ 'cronExpression',
1208
+ input.cronExpression,
1209
+ 'jobType',
1210
+ input.jobType,
1211
+ 'payload',
1212
+ JSON.stringify(input.payload),
1213
+ 'maxAttempts',
1214
+ input.maxAttempts.toString(),
1215
+ 'priority',
1216
+ input.priority.toString(),
1217
+ 'timeoutMs',
1218
+ input.timeoutMs !== null ? input.timeoutMs.toString() : 'null',
1219
+ 'forceKillOnTimeout',
1220
+ input.forceKillOnTimeout ? 'true' : 'false',
1221
+ 'tags',
1222
+ input.tags ? JSON.stringify(input.tags) : 'null',
1223
+ 'timezone',
1224
+ input.timezone,
1225
+ 'allowOverlap',
1226
+ input.allowOverlap ? 'true' : 'false',
1227
+ 'status',
1228
+ 'active',
1229
+ 'lastEnqueuedAt',
1230
+ 'null',
1231
+ 'lastJobId',
1232
+ 'null',
1233
+ 'nextRunAt',
1234
+ input.nextRunAt ? input.nextRunAt.getTime().toString() : 'null',
1235
+ 'createdAt',
1236
+ now.toString(),
1237
+ 'updatedAt',
1238
+ now.toString(),
1239
+ ];
1240
+
1241
+ await (this.client as any).hmset(key, ...fields);
1242
+ await this.client.set(
1243
+ `${this.prefix}cron_name:${input.scheduleName}`,
1244
+ id.toString(),
1245
+ );
1246
+ await this.client.sadd(`${this.prefix}crons`, id.toString());
1247
+ await this.client.sadd(`${this.prefix}cron_status:active`, id.toString());
1248
+
1249
+ if (input.nextRunAt) {
1250
+ await this.client.zadd(
1251
+ `${this.prefix}cron_due`,
1252
+ input.nextRunAt.getTime(),
1253
+ id.toString(),
1254
+ );
1255
+ }
1256
+
1257
+ log(`Added cron schedule ${id}: "${input.scheduleName}"`);
1258
+ return id;
1259
+ }
1260
+
1261
+ /** Get a cron schedule by ID. */
1262
+ async getCronSchedule(id: number): Promise<CronScheduleRecord | null> {
1263
+ const data = await this.client.hgetall(`${this.prefix}cron:${id}`);
1264
+ if (!data || Object.keys(data).length === 0) return null;
1265
+ return this.deserializeCronSchedule(data);
1266
+ }
1267
+
1268
+ /** Get a cron schedule by its unique name. */
1269
+ async getCronScheduleByName(
1270
+ name: string,
1271
+ ): Promise<CronScheduleRecord | null> {
1272
+ const id = await this.client.get(`${this.prefix}cron_name:${name}`);
1273
+ if (id === null) return null;
1274
+ return this.getCronSchedule(Number(id));
1275
+ }
1276
+
1277
+ /** List cron schedules, optionally filtered by status. */
1278
+ async listCronSchedules(
1279
+ status?: CronScheduleStatus,
1280
+ ): Promise<CronScheduleRecord[]> {
1281
+ let ids: string[];
1282
+ if (status) {
1283
+ ids = await this.client.smembers(`${this.prefix}cron_status:${status}`);
1284
+ } else {
1285
+ ids = await this.client.smembers(`${this.prefix}crons`);
1286
+ }
1287
+ if (ids.length === 0) return [];
1288
+
1289
+ const pipeline = this.client.pipeline();
1290
+ for (const id of ids) {
1291
+ pipeline.hgetall(`${this.prefix}cron:${id}`);
1292
+ }
1293
+ const results = await pipeline.exec();
1294
+ const schedules: CronScheduleRecord[] = [];
1295
+ if (results) {
1296
+ for (const [err, data] of results) {
1297
+ if (
1298
+ !err &&
1299
+ data &&
1300
+ typeof data === 'object' &&
1301
+ Object.keys(data as object).length > 0
1302
+ ) {
1303
+ schedules.push(
1304
+ this.deserializeCronSchedule(data as Record<string, string>),
1305
+ );
1306
+ }
1307
+ }
1308
+ }
1309
+ schedules.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
1310
+ return schedules;
1311
+ }
1312
+
1313
+ /** Delete a cron schedule by ID. */
1314
+ async removeCronSchedule(id: number): Promise<void> {
1315
+ const data = await this.client.hgetall(`${this.prefix}cron:${id}`);
1316
+ if (!data || Object.keys(data).length === 0) return;
1317
+
1318
+ const name = data.scheduleName;
1319
+ const status = data.status;
1320
+
1321
+ await this.client.del(`${this.prefix}cron:${id}`);
1322
+ await this.client.del(`${this.prefix}cron_name:${name}`);
1323
+ await this.client.srem(`${this.prefix}crons`, id.toString());
1324
+ await this.client.srem(
1325
+ `${this.prefix}cron_status:${status}`,
1326
+ id.toString(),
1327
+ );
1328
+ await this.client.zrem(`${this.prefix}cron_due`, id.toString());
1329
+ log(`Removed cron schedule ${id}`);
1330
+ }
1331
+
1332
+ /** Pause a cron schedule. */
1333
+ async pauseCronSchedule(id: number): Promise<void> {
1334
+ const now = this.nowMs();
1335
+ await this.client.hset(
1336
+ `${this.prefix}cron:${id}`,
1337
+ 'status',
1338
+ 'paused',
1339
+ 'updatedAt',
1340
+ now.toString(),
1341
+ );
1342
+ await this.client.srem(`${this.prefix}cron_status:active`, id.toString());
1343
+ await this.client.sadd(`${this.prefix}cron_status:paused`, id.toString());
1344
+ await this.client.zrem(`${this.prefix}cron_due`, id.toString());
1345
+ log(`Paused cron schedule ${id}`);
1346
+ }
1347
+
1348
+ /** Resume a paused cron schedule. */
1349
+ async resumeCronSchedule(id: number): Promise<void> {
1350
+ const now = this.nowMs();
1351
+ await this.client.hset(
1352
+ `${this.prefix}cron:${id}`,
1353
+ 'status',
1354
+ 'active',
1355
+ 'updatedAt',
1356
+ now.toString(),
1357
+ );
1358
+ await this.client.srem(`${this.prefix}cron_status:paused`, id.toString());
1359
+ await this.client.sadd(`${this.prefix}cron_status:active`, id.toString());
1360
+
1361
+ const nextRunAt = await this.client.hget(
1362
+ `${this.prefix}cron:${id}`,
1363
+ 'nextRunAt',
1364
+ );
1365
+ if (nextRunAt && nextRunAt !== 'null') {
1366
+ await this.client.zadd(
1367
+ `${this.prefix}cron_due`,
1368
+ Number(nextRunAt),
1369
+ id.toString(),
1370
+ );
1371
+ }
1372
+ log(`Resumed cron schedule ${id}`);
1373
+ }
1374
+
1375
+ /** Edit a cron schedule. */
1376
+ async editCronSchedule(
1377
+ id: number,
1378
+ updates: EditCronScheduleOptions,
1379
+ nextRunAt?: Date | null,
1380
+ ): Promise<void> {
1381
+ const now = this.nowMs();
1382
+ const fields: string[] = [];
1383
+
1384
+ if (updates.cronExpression !== undefined) {
1385
+ fields.push('cronExpression', updates.cronExpression);
1386
+ }
1387
+ if (updates.payload !== undefined) {
1388
+ fields.push('payload', JSON.stringify(updates.payload));
1389
+ }
1390
+ if (updates.maxAttempts !== undefined) {
1391
+ fields.push('maxAttempts', updates.maxAttempts.toString());
1392
+ }
1393
+ if (updates.priority !== undefined) {
1394
+ fields.push('priority', updates.priority.toString());
1395
+ }
1396
+ if (updates.timeoutMs !== undefined) {
1397
+ fields.push(
1398
+ 'timeoutMs',
1399
+ updates.timeoutMs !== null ? updates.timeoutMs.toString() : 'null',
1400
+ );
1401
+ }
1402
+ if (updates.forceKillOnTimeout !== undefined) {
1403
+ fields.push(
1404
+ 'forceKillOnTimeout',
1405
+ updates.forceKillOnTimeout ? 'true' : 'false',
1406
+ );
1407
+ }
1408
+ if (updates.tags !== undefined) {
1409
+ fields.push(
1410
+ 'tags',
1411
+ updates.tags !== null ? JSON.stringify(updates.tags) : 'null',
1412
+ );
1413
+ }
1414
+ if (updates.timezone !== undefined) {
1415
+ fields.push('timezone', updates.timezone);
1416
+ }
1417
+ if (updates.allowOverlap !== undefined) {
1418
+ fields.push('allowOverlap', updates.allowOverlap ? 'true' : 'false');
1419
+ }
1420
+ if (nextRunAt !== undefined) {
1421
+ const val = nextRunAt !== null ? nextRunAt.getTime().toString() : 'null';
1422
+ fields.push('nextRunAt', val);
1423
+ if (nextRunAt !== null) {
1424
+ await this.client.zadd(
1425
+ `${this.prefix}cron_due`,
1426
+ nextRunAt.getTime(),
1427
+ id.toString(),
1428
+ );
1429
+ } else {
1430
+ await this.client.zrem(`${this.prefix}cron_due`, id.toString());
1431
+ }
1432
+ }
1433
+
1434
+ if (fields.length === 0) {
1435
+ log(`No fields to update for cron schedule ${id}`);
1436
+ return;
1437
+ }
1438
+
1439
+ fields.push('updatedAt', now.toString());
1440
+ await (this.client as any).hmset(`${this.prefix}cron:${id}`, ...fields);
1441
+ log(`Edited cron schedule ${id}`);
1442
+ }
1443
+
1444
+ /**
1445
+ * Fetch all active cron schedules whose nextRunAt <= now.
1446
+ * Uses a sorted set (cron_due) for efficient range query.
1447
+ */
1448
+ async getDueCronSchedules(): Promise<CronScheduleRecord[]> {
1449
+ const now = this.nowMs();
1450
+ const ids = await this.client.zrangebyscore(
1451
+ `${this.prefix}cron_due`,
1452
+ 0,
1453
+ now,
1454
+ );
1455
+ if (ids.length === 0) {
1456
+ log('Found 0 due cron schedules');
1457
+ return [];
1458
+ }
1459
+
1460
+ const schedules: CronScheduleRecord[] = [];
1461
+ for (const id of ids) {
1462
+ const data = await this.client.hgetall(`${this.prefix}cron:${id}`);
1463
+ if (data && Object.keys(data).length > 0 && data.status === 'active') {
1464
+ schedules.push(this.deserializeCronSchedule(data));
1465
+ }
1466
+ }
1467
+ log(`Found ${schedules.length} due cron schedules`);
1468
+ return schedules;
1469
+ }
1470
+
1471
+ /**
1472
+ * Update a cron schedule after a job has been enqueued.
1473
+ * Sets lastEnqueuedAt, lastJobId, and advances nextRunAt.
1474
+ */
1475
+ async updateCronScheduleAfterEnqueue(
1476
+ id: number,
1477
+ lastEnqueuedAt: Date,
1478
+ lastJobId: number,
1479
+ nextRunAt: Date | null,
1480
+ ): Promise<void> {
1481
+ const fields: string[] = [
1482
+ 'lastEnqueuedAt',
1483
+ lastEnqueuedAt.getTime().toString(),
1484
+ 'lastJobId',
1485
+ lastJobId.toString(),
1486
+ 'nextRunAt',
1487
+ nextRunAt ? nextRunAt.getTime().toString() : 'null',
1488
+ 'updatedAt',
1489
+ this.nowMs().toString(),
1490
+ ];
1491
+
1492
+ await (this.client as any).hmset(`${this.prefix}cron:${id}`, ...fields);
1493
+
1494
+ if (nextRunAt) {
1495
+ await this.client.zadd(
1496
+ `${this.prefix}cron_due`,
1497
+ nextRunAt.getTime(),
1498
+ id.toString(),
1499
+ );
1500
+ } else {
1501
+ await this.client.zrem(`${this.prefix}cron_due`, id.toString());
1502
+ }
1503
+
1504
+ log(
1505
+ `Updated cron schedule ${id}: lastJobId=${lastJobId}, nextRunAt=${nextRunAt?.toISOString() ?? 'null'}`,
1506
+ );
1507
+ }
1508
+
1509
+ /** Deserialize a Redis hash into a CronScheduleRecord. */
1510
+ private deserializeCronSchedule(
1511
+ h: Record<string, string>,
1512
+ ): CronScheduleRecord {
1513
+ const nullish = (v: string | undefined) =>
1514
+ v === undefined || v === 'null' || v === '' ? null : v;
1515
+ const numOrNull = (v: string | undefined): number | null => {
1516
+ const n = nullish(v);
1517
+ return n === null ? null : Number(n);
1518
+ };
1519
+ const dateOrNull = (v: string | undefined): Date | null => {
1520
+ const n = numOrNull(v);
1521
+ return n === null ? null : new Date(n);
1522
+ };
1523
+
1524
+ let payload: any;
1525
+ try {
1526
+ payload = JSON.parse(h.payload);
1527
+ } catch {
1528
+ payload = h.payload;
1529
+ }
1530
+
1531
+ let tags: string[] | undefined;
1532
+ try {
1533
+ const raw = h.tags;
1534
+ if (raw && raw !== 'null') {
1535
+ tags = JSON.parse(raw);
1536
+ }
1537
+ } catch {
1538
+ /* ignore */
1539
+ }
1540
+
1541
+ return {
1542
+ id: Number(h.id),
1543
+ scheduleName: h.scheduleName,
1544
+ cronExpression: h.cronExpression,
1545
+ jobType: h.jobType,
1546
+ payload,
1547
+ maxAttempts: Number(h.maxAttempts),
1548
+ priority: Number(h.priority),
1549
+ timeoutMs: numOrNull(h.timeoutMs),
1550
+ forceKillOnTimeout: h.forceKillOnTimeout === 'true',
1551
+ tags,
1552
+ timezone: h.timezone,
1553
+ allowOverlap: h.allowOverlap === 'true',
1554
+ status: h.status as CronScheduleStatus,
1555
+ lastEnqueuedAt: dateOrNull(h.lastEnqueuedAt),
1556
+ lastJobId: numOrNull(h.lastJobId),
1557
+ nextRunAt: dateOrNull(h.nextRunAt),
1558
+ createdAt: new Date(Number(h.createdAt)),
1559
+ updatedAt: new Date(Number(h.updatedAt)),
1560
+ };
1561
+ }
1562
+
1563
+ // ── Private helpers (filters) ─────────────────────────────────────────
1564
+
798
1565
  private async applyFilters(
799
1566
  ids: string[],
800
1567
  filters: JobFilters,