pg-boss 12.36.0 → 12.37.1

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.
package/dist/plans.js CHANGED
@@ -3,7 +3,11 @@ import { indexKeysRaw, indexIncludeRaw, indexPredicateRaw, displayIndexDefinitio
3
3
  import schemaManifest from './schema.json' with { type: 'json' };
4
4
  import { normalizeSchemaName, resolveSchemaName } from "./tools.js";
5
5
  export const PG_ERROR = {
6
- divisionByZero: '22012'
6
+ divisionByZero: '22012',
7
+ lockNotAvailable: '55P03',
8
+ foreignKeyViolation: '23503',
9
+ undefinedTable: '42P01',
10
+ checkViolation: '23514'
7
11
  };
8
12
  export const DEFAULT_SCHEMA = 'pgboss';
9
13
  export const MIGRATE_RACE_MESSAGE = 'division by zero';
@@ -854,7 +858,8 @@ function createTableJob(schema, noPartitioning = false) {
854
858
  source_retry_count int,
855
859
  source_output jsonb,
856
860
  source_root_id uuid,
857
- trace_context jsonb
861
+ trace_context jsonb,
862
+ upsert_by_key bool
858
863
  ) ${partitionClause}
859
864
  `;
860
865
  }
@@ -909,6 +914,7 @@ function createTableJobCommon(schema) {
909
914
  SELECT ${schema}.job_table_run($cmd$${createIndexJobGroupConcurrency(schema)}$cmd$, '${COMMON_JOB_TABLE}');
910
915
  SELECT ${schema}.job_table_run($cmd$${createIndexJobBlocking(schema)}$cmd$, '${COMMON_JOB_TABLE}');
911
916
  SELECT ${schema}.job_table_run($cmd$${createIndexJobSourceRoot(schema)}$cmd$, '${COMMON_JOB_TABLE}');
917
+ SELECT ${schema}.job_table_run($cmd$${createIndexJobUpsert(schema)}$cmd$, '${COMMON_JOB_TABLE}');
912
918
 
913
919
  ALTER TABLE ${schema}.job ATTACH PARTITION ${schema}.${COMMON_JOB_TABLE} DEFAULT;
914
920
  `;
@@ -930,6 +936,7 @@ function createTableJobIndexes(schema, noDeferrableConstraints = false, noCoveri
930
936
  ${createIndexJobGroupConcurrency(schema)};
931
937
  ${createIndexJobBlocking(schema)};
932
938
  ${createIndexJobSourceRoot(schema)};
939
+ ${createIndexJobUpsert(schema)};
933
940
  `;
934
941
  }
935
942
  function createQueueFunction(schema, noPartitioning = false) {
@@ -1053,6 +1060,7 @@ function createQueueFunction(schema, noPartitioning = false) {
1053
1060
  EXECUTE ${schema}.job_table_format($cmd$${createIndexJobGroupConcurrency(schema)}$cmd$, tablename);
1054
1061
  EXECUTE ${schema}.job_table_format($cmd$${createIndexJobBlocking(schema)}$cmd$, tablename);
1055
1062
  EXECUTE ${schema}.job_table_format($cmd$${createIndexJobSourceRoot(schema)}$cmd$, tablename);
1063
+ EXECUTE ${schema}.job_table_format($cmd$${createIndexJobUpsert(schema)}$cmd$, tablename);
1056
1064
 
1057
1065
  IF options->>'policy' = 'short' THEN
1058
1066
  EXECUTE ${schema}.job_table_format($cmd$${createIndexJobPolicyShort(schema)}$cmd$, tablename);
@@ -1135,8 +1143,32 @@ export function notifyChannelSql(schema) {
1135
1143
  export function notifyQueue(schema, name) {
1136
1144
  return `SELECT pg_notify(${notifyChannelSql(schema)}, '${name}')`;
1137
1145
  }
1138
- export function deleteQueue(schema, name, noAdvisoryLocks) {
1139
- const sql = `SELECT ${schema}.delete_queue('${name}')`;
1146
+ // Dropping a queue's own table (partition: true) needs ACCESS EXCLUSIVE on it, on job and on job_common,
1147
+ // and an insert into job_common or a query through job takes those in other orders. So the locks are
1148
+ // taken first, all with NOWAIT: the transaction never waits holding one, so it can never be part of a
1149
+ // deadlock, and the caller tries again when one is busy. The table is read in the same transaction, so
1150
+ // a stale cache cannot name the wrong one, and a queue already gone is left alone.
1151
+ export function deleteQueue(schema, name, noAdvisoryLocks, partitioned = false) {
1152
+ const sql = partitioned
1153
+ ? `
1154
+ DO $$
1155
+ DECLARE
1156
+ v_table text;
1157
+ v_partition bool;
1158
+ BEGIN
1159
+ SELECT table_name, partition FROM ${schema}.queue WHERE name = '${name}' INTO v_table, v_partition;
1160
+ IF NOT FOUND THEN
1161
+ RETURN;
1162
+ END IF;
1163
+
1164
+ IF v_partition AND to_regclass(format('${schema}.%I', v_table)) IS NOT NULL THEN
1165
+ EXECUTE format('LOCK TABLE ${schema}.${COMMON_JOB_TABLE}, ${schema}.%I, ${schema}.${BASE_JOB_TABLE} IN ACCESS EXCLUSIVE MODE NOWAIT', v_table);
1166
+ END IF;
1167
+
1168
+ PERFORM ${schema}.delete_queue('${name}');
1169
+ END $$
1170
+ `
1171
+ : `SELECT ${schema}.delete_queue('${name}')`;
1140
1172
  return locked(schema, sql, 'delete-queue', noAdvisoryLocks);
1141
1173
  }
1142
1174
  function createPrimaryKeyJob(schema) {
@@ -1230,6 +1262,14 @@ function createIndexJobBlocking(schema) {
1230
1262
  function createIndexJobSourceRoot(schema) {
1231
1263
  return `CREATE INDEX job_i12 ON ${schema}.job (source_root_id) WHERE source_root_id IS NOT NULL`;
1232
1264
  }
1265
+ // At most one waiting job per key among the jobs upsert() inserted by singletonKey, which is what makes
1266
+ // concurrent upserts of one key agree: the second insert waits on the first's index entry, conflicts,
1267
+ // and edits that job instead. upsert_by_key is set only on those inserts, so send() keeps its own policy's
1268
+ // rules and a queue that never upserts carries the index empty. Only created, not retry, so a failing
1269
+ // job never collides with a newer upsert on its way back to retry.
1270
+ function createIndexJobUpsert(schema) {
1271
+ return `CREATE UNIQUE INDEX job_i13 ON ${schema}.job (name, singleton_key) WHERE state = '${JOB_STATES.created}' AND upsert_by_key`;
1272
+ }
1233
1273
  // The interval claim for a monitor pass. It stamps monitor_claim_on, never monitor_on, which only
1234
1274
  // the stats aggregate writes, so a pass that skips the aggregate leaves capturedOn aging.
1235
1275
  //
@@ -2093,8 +2133,7 @@ export function insertVersion(schema, version) {
2093
2133
  return `INSERT INTO ${schema}.version(version) VALUES ('${version}')`;
2094
2134
  }
2095
2135
  function buildFetchParams(options) {
2096
- const { ignoreSingletons, ignoreGroups, groupConcurrency, minPriority, maxPriority } = options;
2097
- const hasIgnoreSingletons = ignoreSingletons != null && ignoreSingletons.length > 0;
2136
+ const { ignoreGroups, groupConcurrency, minPriority, maxPriority } = options;
2098
2137
  const hasIgnoreGroups = ignoreGroups != null && ignoreGroups.length > 0;
2099
2138
  const hasGroupConcurrency = groupConcurrency != null;
2100
2139
  const hasMinPriority = minPriority != null;
@@ -2105,22 +2144,11 @@ function buildFetchParams(options) {
2105
2144
  const hasTiers = groupConcurrencyConfig?.tiers && Object.keys(groupConcurrencyConfig.tiers).length > 0;
2106
2145
  const values = [];
2107
2146
  let paramIndex = 0;
2108
- let ignoreSingletonsParam = '';
2109
2147
  let ignoreGroupsParam = '';
2110
2148
  let defaultGroupLimitParam = '';
2111
2149
  let tiersParam = '';
2112
2150
  let minPriorityParam = '';
2113
2151
  let maxPriorityParam = '';
2114
- if (hasIgnoreSingletons) {
2115
- paramIndex++;
2116
- ignoreSingletonsParam = `$${paramIndex}::text[]`;
2117
- // job_i2/job_i3 key singleton/stately jobs on the empty key (COALESCE(singleton_key, ''))
2118
- // as one slot, so a keyless active job must block keyless pending jobs the same way a keyed
2119
- // one blocks its key. Map null -> '' here so the WHERE clause's COALESCE comparison (below)
2120
- // never has to compare against a literal NULL array element, which would make `<> ALL(...)`
2121
- // evaluate to NULL (excluding every row) instead of the intended per-key filter.
2122
- values.push(ignoreSingletons.map(key => key ?? ''));
2123
- }
2124
2152
  if (hasIgnoreGroups) {
2125
2153
  paramIndex++;
2126
2154
  ignoreGroupsParam = `$${paramIndex}::text[]`;
@@ -2146,7 +2174,7 @@ function buildFetchParams(options) {
2146
2174
  maxPriorityParam = `$${paramIndex}::int`;
2147
2175
  values.push(maxPriority);
2148
2176
  }
2149
- return { values, ignoreSingletonsParam, ignoreGroupsParam, defaultGroupLimitParam, tiersParam, minPriorityParam, maxPriorityParam };
2177
+ return { values, ignoreGroupsParam, defaultGroupLimitParam, tiersParam, minPriorityParam, maxPriorityParam };
2150
2178
  }
2151
2179
  /**
2152
2180
  * Builds the fetch query for claiming jobs from the queue.
@@ -2167,8 +2195,8 @@ function buildFetchParams(options) {
2167
2195
  export function fetchNextJob(options, noSkipLocked = false) {
2168
2196
  const { schema, table, name, policy, limit, includeMetadata, includeTraceContext = false, ignoreStartAfter = false, groupConcurrency, minPriority, maxPriority } = options;
2169
2197
  const keyStrictFifo = policy === QUEUE_POLICIES.key_strict_fifo;
2170
- const singletonFetch = limit > 1 && (policy === QUEUE_POLICIES.singleton || policy === QUEUE_POLICIES.stately);
2171
- const hasIgnoreSingletons = options.ignoreSingletons != null && options.ignoreSingletons.length > 0;
2198
+ const singletonPolicy = policy === QUEUE_POLICIES.singleton || policy === QUEUE_POLICIES.stately;
2199
+ const singletonFetch = limit > 1 && singletonPolicy;
2172
2200
  const hasIgnoreGroups = options.ignoreGroups != null && options.ignoreGroups.length > 0;
2173
2201
  const hasGroupConcurrency = groupConcurrency != null;
2174
2202
  const hasMinPriority = minPriority != null;
@@ -2266,7 +2294,19 @@ export function fetchNextJob(options, noSkipLocked = false) {
2266
2294
  AND b.id <> j.id
2267
2295
  )`
2268
2296
  : '',
2269
- hasIgnoreSingletons ? `COALESCE(j.singleton_key, '') <> ALL(${params.ignoreSingletonsParam})` : '',
2297
+ // Skips a key whose active slot is taken, so its pending job does not lose the fetch to a
2298
+ // job_i2/job_i3 conflict and block the keys behind it. Read live: queue stats can be one
2299
+ // monitor interval old or more, and a stale active key would block the key with nothing active.
2300
+ singletonPolicy
2301
+ ? `NOT EXISTS (
2302
+ SELECT 1
2303
+ FROM ${schema}.${table} singleton_probe
2304
+ WHERE singleton_probe.name = j.name
2305
+ AND singleton_probe.state = '${JOB_STATES.active}'
2306
+ AND singleton_probe.policy = '${policy}'
2307
+ AND COALESCE(singleton_probe.singleton_key, '') = COALESCE(j.singleton_key, '')
2308
+ )`
2309
+ : '',
2270
2310
  hasIgnoreGroups ? `(j.group_id IS NULL OR j.group_id <> ALL(${params.ignoreGroupsParam}))` : '',
2271
2311
  hasMinPriority ? `j.priority >= ${params.minPriorityParam}` : '',
2272
2312
  hasMaxPriority ? `j.priority <= ${params.maxPriorityParam}` : '',
@@ -2520,13 +2560,16 @@ export function cancelJobs(schema, table, fenced) {
2520
2560
  }
2521
2561
  // A resumed job's start_after moves up to now, as a released flow child's does, so its wait (in the
2522
2562
  // monitor's histograms and ready_oldest_seconds) counts from when it could run again rather than
2523
- // from when it was first sent. A start_after still in the future is kept.
2563
+ // from when it was first sent. A start_after still in the future is kept. It also loses upsert_by_key,
2564
+ // here and in restoreJobs, so it never collides in job_i13 with a newer job upserted by the same key,
2565
+ // and queues beside it.
2524
2566
  export function resumeJobs(schema, table) {
2525
2567
  return `
2526
2568
  WITH results as (
2527
2569
  UPDATE ${schema}.${table}
2528
2570
  SET completed_on = NULL,
2529
2571
  state = '${JOB_STATES.created}',
2572
+ upsert_by_key = NULL,
2530
2573
  start_after = GREATEST(start_after, ${schema}.job_now())
2531
2574
  WHERE name = $1
2532
2575
  AND id = ANY($2::uuid[])
@@ -2541,7 +2584,8 @@ export function restoreJobs(schema, table) {
2541
2584
  UPDATE ${schema}.${table}
2542
2585
  SET state = '${JOB_STATES.created}',
2543
2586
  started_on = NULL,
2544
- heartbeat_on = NULL
2587
+ heartbeat_on = NULL,
2588
+ upsert_by_key = NULL
2545
2589
  WHERE name = $1
2546
2590
  AND id = ANY($2::uuid[])
2547
2591
  `;
@@ -2566,7 +2610,11 @@ export function restoreJobs(schema, table) {
2566
2610
  function isDateTimeString(expression) {
2567
2611
  return `(right(${expression}, 1) = 'Z' OR ${expression} ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}')`;
2568
2612
  }
2569
- export function insertJobs(schema, { table, name, returnId = true, notify = false, slots = false }) {
2613
+ // The queue is LEFT JOINed and every NOT NULL column it supplies falls back to the schema default, so
2614
+ // a job for a queue deleted after the caller cached it still reaches q_fkey and fails there (23503),
2615
+ // rather than producing no row, which would read the same as a singleton or throttle refusal. The
2616
+ // fallbacks never apply to a queue that exists, since its own columns are NOT NULL.
2617
+ export function insertJobs(schema, { table, name, returnId = true, notify = false, slots = false, upsertByKey = false }) {
2570
2618
  // When notify is enabled we always RETURN start_after so the wrapper below can gate
2571
2619
  // the NOTIFY on immediate availability, regardless of whether the caller wants ids.
2572
2620
  const returning = notify ? 'RETURNING id, start_after' : returnId ? 'RETURNING id' : '';
@@ -2603,7 +2651,7 @@ export function insertJobs(schema, { table, name, returnId = true, notify = fals
2603
2651
  blocked,
2604
2652
  blocking,
2605
2653
  pending_dependencies,
2606
- trace_context
2654
+ trace_context${upsertByKey ? ', upsert_by_key' : ''}
2607
2655
  )
2608
2656
  SELECT
2609
2657
  COALESCE(id, gen_random_uuid()) as id,
@@ -2620,12 +2668,12 @@ export function insertJobs(schema, { table, name, returnId = true, notify = fals
2620
2668
  END as singleton_on,
2621
2669
  "groupId" as group_id,
2622
2670
  "groupTier" as group_tier,
2623
- COALESCE("expireInSeconds", q.expire_seconds) as expire_seconds,
2624
- COALESCE("deleteAfterSeconds", q.deletion_seconds) as deletion_seconds,
2625
- j.start_after + (COALESCE("retentionSeconds", q.retention_seconds) * interval '1s') as keep_until,
2626
- COALESCE("retryLimit", q.retry_limit) as retry_limit,
2627
- COALESCE("retryDelay", q.retry_delay) as retry_delay,
2628
- COALESCE("retryBackoff", q.retry_backoff, false) as retry_backoff,
2671
+ COALESCE("expireInSeconds", q.expire_seconds, ${QUEUE_DEFAULTS.expire_seconds}) as expire_seconds,
2672
+ COALESCE("deleteAfterSeconds", q.deletion_seconds, ${QUEUE_DEFAULTS.deletion_seconds}) as deletion_seconds,
2673
+ j.start_after + (COALESCE("retentionSeconds", q.retention_seconds, ${QUEUE_DEFAULTS.retention_seconds}) * interval '1s') as keep_until,
2674
+ COALESCE("retryLimit", q.retry_limit, ${QUEUE_DEFAULTS.retry_limit}) as retry_limit,
2675
+ COALESCE("retryDelay", q.retry_delay, ${QUEUE_DEFAULTS.retry_delay}) as retry_delay,
2676
+ COALESCE("retryBackoff", q.retry_backoff, ${QUEUE_DEFAULTS.retry_backoff}) as retry_backoff,
2629
2677
  COALESCE("retryDelayMax", q.retry_delay_max) as retry_delay_max,
2630
2678
  q.policy,
2631
2679
  COALESCE("deadLetter", q.dead_letter) as dead_letter,
@@ -2633,7 +2681,7 @@ export function insertJobs(schema, { table, name, returnId = true, notify = fals
2633
2681
  COALESCE(blocked, false) as blocked,
2634
2682
  COALESCE(blocking, false) as blocking,
2635
2683
  COALESCE("pendingDependencies", 0) as pending_dependencies,
2636
- "__traceContext" as trace_context
2684
+ "__traceContext" as trace_context${upsertByKey ? ', true as upsert_by_key' : ''}
2637
2685
  FROM (
2638
2686
  SELECT *,
2639
2687
  CASE
@@ -2666,7 +2714,7 @@ export function insertJobs(schema, { table, name, returnId = true, notify = fals
2666
2714
  "__traceContext" jsonb
2667
2715
  )
2668
2716
  ) j
2669
- JOIN ${schema}.queue q ON q.name = '${name}'
2717
+ LEFT JOIN ${schema}.queue q ON q.name = '${name}'
2670
2718
  ON CONFLICT DO NOTHING
2671
2719
  ${returning}
2672
2720
  `;
@@ -3910,6 +3958,24 @@ export function cacheQueueStats(schema, table, queues, noAdvisoryLocks, throughp
3910
3958
  // Serialize the $1 parameter for use in the multi-statement transaction below
3911
3959
  const statsText = statsQuery.text.replaceAll('$1::text[]', serializeArrayParam(queues));
3912
3960
  const lock = tryAdvisoryLock(schema, 'queue-stats', noAdvisoryLocks);
3961
+ // Only when the try-lock was lost. Clearing the claim makes the queue due from monitor_on, which is
3962
+ // already a full interval old, so the next pass claims it again. No other instance can have
3963
+ // claimed it since this pass did, because the claim is not due again yet. The rows are locked in
3964
+ // name order like everywhere else.
3965
+ const release = lock.cte
3966
+ ? `released AS (
3967
+ UPDATE ${schema}.queue SET monitor_claim_on = NULL
3968
+ FROM (
3969
+ SELECT name
3970
+ FROM ${schema}.queue
3971
+ WHERE name = ANY(${serializeArrayParam(queues)}) AND NOT (SELECT got FROM lock)
3972
+ ORDER BY name
3973
+ FOR NO KEY UPDATE
3974
+ ) r
3975
+ WHERE queue.name = r.name
3976
+ ),
3977
+ `
3978
+ : '';
3913
3979
  // Two columns in here are not counts and are easy to mistake for incidental:
3914
3980
  //
3915
3981
  // monitor_on is stamped by this statement and by refreshQueueStats - the two that write counts -
@@ -3936,7 +4002,7 @@ export function cacheQueueStats(schema, table, queues, noAdvisoryLocks, throughp
3936
4002
  // Its count over stats is there only to finish the aggregate before the first row is locked, so no
3937
4003
  // queue row stays locked through the job table scan.
3938
4004
  const sql = `
3939
- WITH ${lock.cte}stats AS (SELECT * FROM (${statsText}) agg WHERE true${lock.guard})
4005
+ WITH ${lock.cte}${release}stats AS (SELECT * FROM (${statsText}) agg WHERE true${lock.guard})
3940
4006
  UPDATE ${schema}.queue SET
3941
4007
  deferred_count = COALESCE(stats."deferredCount", 0),
3942
4008
  blocked_count = COALESCE(stats."blockedCount", 0),
@@ -4088,12 +4154,14 @@ function advisoryLock(schema, key) {
4088
4154
  // overlapping-analytics regime this whole subsystem exists to prevent, manufactured by the lock
4089
4155
  // meant to prevent duplicate work.
4090
4156
  //
4091
- // So the loser does nothing. trySetQueueMonitorTime stamped monitor_claim_on a statement earlier,
4092
- // when it claimed the interval and before this lock was attempted, so those queues sit out one
4093
- // interval rather than retrying at once - their counts end up at most one monitorIntervalSeconds
4094
- // behind, and monitor_on keeps aging so capturedOn says so.
4095
- // Against a staleness budget measured in hours, losing one sample costs a gap in the sparkline;
4096
- // winning the race to duplicate a scan nobody asked for costs the job table.
4157
+ // So the loser does not count. cacheQueueStats also hands back the interval claim that
4158
+ // trySetQueueMonitorTime stamped a statement earlier, so those queues are due again on the next
4159
+ // pass of any instance instead of a whole monitorIntervalSeconds later. Keeping the claim looked
4160
+ // like losing one sample, but it does not stay one. A pass claims and counts one chunk (or one
4161
+ // partitioned table) at a time, so a second instance whose pass overlaps claims every queue the
4162
+ // first has not reached yet, and loses the lock to the first one's aggregate. Both sets are then
4163
+ // stamped together, come due together, and race again the next interval. With 3 instances and
4164
+ // 250 queues that was 41% of claims never counted and runs of 10 in a row.
4097
4165
  //
4098
4166
  // The guard is a one-time filter, so a lost race skips the scan rather than running and discarding
4099
4167
  // it, verified on the plan: `Parallel Seq Scan on job (never executed)`. It also has to gate the
@@ -4461,6 +4529,32 @@ function manifestSection(partitioned) {
4461
4529
  function applyManifestSchema(text, schema) {
4462
4530
  return text.split(schemaManifest.schemaToken).join(schema);
4463
4531
  }
4532
+ // The argument types in a manifest function's definition, without names or defaults, as DROP FUNCTION
4533
+ // takes them: "create_queue(queue_name text, options jsonb)" gives "text, jsonb".
4534
+ function functionArgTypes(def) {
4535
+ const args = def.slice(def.indexOf('(') + 1, def.indexOf(')'));
4536
+ return args.split(',')
4537
+ .map(arg => arg.trim().replace(/\s+DEFAULT\s+.*$/i, ''))
4538
+ .filter(Boolean)
4539
+ .map(arg => arg.split(/\s+/).slice(1).join(' '))
4540
+ .join(', ');
4541
+ }
4542
+ // Removes everything create() installs, for a schema pg-boss shares with other objects; a schema of
4543
+ // its own is simply dropped. Read from the manifest, so it covers whatever this version installs.
4544
+ // Dropping job takes every queue's own table with it: a partitioned queue's table is a partition of
4545
+ // job, and without partitioning every queue's jobs are in job itself. queue_stats' daily partitions
4546
+ // go with queue_stats the same way.
4547
+ export function uninstall(schema, partitioned = true) {
4548
+ const section = manifestSection(partitioned);
4549
+ const tables = section.tables.map(table => `${schema}.${table}`).join(', ');
4550
+ const functions = section.functions.map(fn => `${schema}.${fn.name}(${functionArgTypes(fn.def)})`).join(', ');
4551
+ // Functions first: CockroachDB records a function as depending on the tables its body names.
4552
+ return [
4553
+ `DROP FUNCTION IF EXISTS ${functions};`,
4554
+ `DROP TABLE IF EXISTS ${tables};`,
4555
+ `DROP TYPE IF EXISTS ${schema}.job_state;`
4556
+ ].join('\n');
4557
+ }
4464
4558
  // The job_state enum values in declaration order, from the manifest (both sections carry the same enum).
4465
4559
  // Order is significant. The numeric base type makes created < retry < … < failed load-bearing.
4466
4560
  export const EXPECTED_JOB_STATES = schemaManifest.partitioned.enum;
@@ -8,7 +8,7 @@ declare class Registrar extends EventEmitter implements types.EventsMixin {
8
8
  error: string;
9
9
  };
10
10
  constructor(id: string, db: types.IDatabase, manager: Manager, config: types.ResolvedConstructorOptions);
11
- start(): Promise<void>;
11
+ start(startAttempt?: number): Promise<void>;
12
12
  stop(): Promise<void>;
13
13
  getInstances(): Promise<types.Instance[]>;
14
14
  }
@@ -1 +1 @@
1
- {"version":3,"file":"registrar.d.ts","sourceRoot":"","sources":["../src/registrar.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AAItC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAEvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAiCnC,cAAM,SAAU,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAC/D,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IAWnB,MAAM;;MAAS;gBAEF,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,0BAA0B;IASlG,KAAK;IAuBL,IAAI;IAuBJ,YAAY,IAAK,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;CA4IjD;AAED,eAAe,SAAS,CAAA"}
1
+ {"version":3,"file":"registrar.d.ts","sourceRoot":"","sources":["../src/registrar.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AAItC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAEvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAiCnC,cAAM,SAAU,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAC/D,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IAYnB,MAAM;;MAAS;gBAEF,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAUlG,KAAK,CAAE,YAAY,SAAI;IAyBvB,IAAI;IAuBJ,YAAY,IAAK,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;CA8IjD;AAED,eAAe,SAAS,CAAA"}
package/dist/registrar.js CHANGED
@@ -43,6 +43,7 @@ class Registrar extends EventEmitter {
43
43
  #nurse = new Nurse();
44
44
  #recount;
45
45
  #active = false;
46
+ #startAttempt = 1;
46
47
  events = events;
47
48
  constructor(id, db, manager, config) {
48
49
  super();
@@ -51,9 +52,11 @@ class Registrar extends EventEmitter {
51
52
  this.#manager = manager;
52
53
  this.#config = config;
53
54
  }
54
- async start() {
55
+ // startAttempt is the start() try this registration comes from, recorded in config when past the first.
56
+ async start(startAttempt = 1) {
55
57
  if (!this.#config.registerInstance || this.#timer)
56
58
  return;
59
+ this.#startAttempt = startAttempt;
57
60
  this.#active = true;
58
61
  this.#nurse.start();
59
62
  try {
@@ -173,6 +176,8 @@ class Registrar extends EventEmitter {
173
176
  if (value !== undefined)
174
177
  config[key] = value;
175
178
  }
179
+ if (this.#startAttempt > 1)
180
+ config.startAttempt = this.#startAttempt;
176
181
  return config;
177
182
  }
178
183
  // One entry per work() call. Each call spawns localConcurrency workers sharing a workId, so they are
package/dist/schema.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_generated": "Generated by scripts/gen-manifest.ts — do not edit by hand. Run `npm run gen:manifest`.",
3
- "version": 44,
3
+ "version": 45,
4
4
  "schemaToken": "{{schema}}",
5
5
  "partitioned": {
6
6
  "tables": [
@@ -521,6 +521,13 @@
521
521
  "notNull": false,
522
522
  "default": null
523
523
  },
524
+ {
525
+ "table": "job_common",
526
+ "column": "upsert_by_key",
527
+ "type": "boolean",
528
+ "notNull": false,
529
+ "default": null
530
+ },
524
531
  {
525
532
  "table": "job_dependency",
526
533
  "column": "child_id",
@@ -801,6 +808,13 @@
801
808
  "notNull": false,
802
809
  "default": null
803
810
  },
811
+ {
812
+ "table": "job",
813
+ "column": "upsert_by_key",
814
+ "type": "boolean",
815
+ "notNull": false,
816
+ "default": null
817
+ },
804
818
  {
805
819
  "table": "queue_stats",
806
820
  "column": "active_count",
@@ -1458,6 +1472,12 @@
1458
1472
  "valid": true,
1459
1473
  "def": "CREATE INDEX job_common_i12 ON {{schema}}.job_common USING btree (source_root_id) WHERE (source_root_id IS NOT NULL)"
1460
1474
  },
1475
+ {
1476
+ "name": "job_common_i13",
1477
+ "table": "job_common",
1478
+ "valid": true,
1479
+ "def": "CREATE UNIQUE INDEX job_common_i13 ON {{schema}}.job_common USING btree (name, singleton_key) WHERE ((state = 'created'::{{schema}}.job_state) AND upsert_by_key)"
1480
+ },
1461
1481
  {
1462
1482
  "name": "job_common_i2",
1463
1483
  "table": "job_common",
@@ -1522,7 +1542,7 @@
1522
1542
  "functions": [
1523
1543
  {
1524
1544
  "name": "create_queue",
1525
- "def": "CREATE OR REPLACE FUNCTION {{schema}}.create_queue(queue_name text, options jsonb)\n RETURNS void\n LANGUAGE plpgsql\nAS $function$\n DECLARE\n tablename varchar := CASE WHEN options->>'partition' = 'true'\n THEN 'j' || encode(sha224(queue_name::bytea), 'hex')\n ELSE 'job_common'\n END;\n queue_created_on timestamptz;\n BEGIN\n\n WITH q as (\n INSERT INTO {{schema}}.queue (\n name,\n policy,\n retry_limit,\n retry_delay,\n retry_backoff,\n retry_delay_max,\n expire_seconds,\n retention_seconds,\n deletion_seconds,\n warning_queued,\n dead_letter,\n partition,\n table_name,\n heartbeat_seconds,\n notify,\n created_on,\n updated_on\n )\n VALUES (\n queue_name,\n options->>'policy',\n COALESCE((options->>'retryLimit')::int, 2),\n COALESCE((options->>'retryDelay')::int, 0),\n COALESCE((options->>'retryBackoff')::bool, false),\n (options->>'retryDelayMax')::int,\n COALESCE((options->>'expireInSeconds')::int, 900),\n COALESCE((options->>'retentionSeconds')::int, 1209600),\n COALESCE((options->>'deleteAfterSeconds')::int, 604800),\n COALESCE((options->>'warningQueueSize')::int, 0),\n options->>'deadLetter',\n COALESCE((options->>'partition')::bool, false),\n tablename,\n (options->>'heartbeatSeconds')::int,\n COALESCE((options->>'notify')::bool, false),\n {{schema}}.job_now(),\n {{schema}}.job_now()\n )\n ON CONFLICT DO NOTHING\n RETURNING created_on\n )\n SELECT created_on into queue_created_on from q;\n\n IF queue_created_on IS NULL OR options->>'partition' IS DISTINCT FROM 'true' THEN\n RETURN;\n END IF;\n\n EXECUTE format('CREATE TABLE {{schema}}.%I (LIKE {{schema}}.job INCLUDING DEFAULTS)', tablename);\n\n EXECUTE {{schema}}.job_table_format($cmd$ALTER TABLE {{schema}}.job ADD PRIMARY KEY (name, id)$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$ALTER TABLE {{schema}}.job ADD CONSTRAINT q_fkey FOREIGN KEY (name) REFERENCES {{schema}}.queue (name) ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$ALTER TABLE {{schema}}.job ADD CONSTRAINT dlq_fkey FOREIGN KEY (dead_letter) REFERENCES {{schema}}.queue (name) ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED$cmd$, tablename);\n\n EXECUTE {{schema}}.job_table_format($cmd$CREATE INDEX job_i11 ON {{schema}}.job (name, priority DESC, created_on, start_after) WHERE state < 'active' AND NOT blocked$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i4 ON {{schema}}.job (name, singleton_on, COALESCE(singleton_key, '')) WHERE state <> 'cancelled' AND singleton_on IS NOT NULL$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$CREATE INDEX job_i7 ON {{schema}}.job (name, group_id) WHERE state = 'active' AND group_id IS NOT NULL$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$CREATE INDEX job_i9 ON {{schema}}.job (name, id) WHERE blocking AND state = 'completed'$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$CREATE INDEX job_i12 ON {{schema}}.job (source_root_id) WHERE source_root_id IS NOT NULL$cmd$, tablename);\n\n IF options->>'policy' = 'short' THEN\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i1 ON {{schema}}.job (name, COALESCE(singleton_key, '')) WHERE state = 'created' AND policy = 'short'$cmd$, tablename);\n ELSIF options->>'policy' = 'singleton' THEN\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i2 ON {{schema}}.job (name, COALESCE(singleton_key, '')) WHERE state = 'active' AND policy = 'singleton'$cmd$, tablename);\n ELSIF options->>'policy' = 'stately' THEN\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i3 ON {{schema}}.job (name, state, COALESCE(singleton_key, '')) WHERE state <= 'active' AND policy = 'stately'$cmd$, tablename);\n ELSIF options->>'policy' = 'exclusive' THEN\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i6 ON {{schema}}.job (name, COALESCE(singleton_key, '')) WHERE state <= 'active' AND policy = 'exclusive'$cmd$, tablename);\n ELSIF options->>'policy' = 'key_strict_fifo' THEN\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i8 ON {{schema}}.job (name, singleton_key) WHERE state IN ('active', 'retry', 'failed') AND policy = 'key_strict_fifo'$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$CREATE INDEX job_i10 ON {{schema}}.job (name, singleton_key, state DESC, created_on, id) INCLUDE (start_after) WHERE state < 'active' AND NOT blocked AND policy = 'key_strict_fifo'$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$ALTER TABLE {{schema}}.job ADD CONSTRAINT job_key_strict_fifo_singleton_key_check CHECK (NOT (policy = 'key_strict_fifo' AND singleton_key IS NULL))$cmd$, tablename);\n END IF;\n\n EXECUTE format('ALTER TABLE {{schema}}.%I ADD CONSTRAINT cjc CHECK (name=%L)', tablename, queue_name);\n EXECUTE format('ALTER TABLE {{schema}}.job ATTACH PARTITION {{schema}}.%I FOR VALUES IN (%L)', tablename, queue_name);\n END;\n $function$\n"
1545
+ "def": "CREATE OR REPLACE FUNCTION {{schema}}.create_queue(queue_name text, options jsonb)\n RETURNS void\n LANGUAGE plpgsql\nAS $function$\n DECLARE\n tablename varchar := CASE WHEN options->>'partition' = 'true'\n THEN 'j' || encode(sha224(queue_name::bytea), 'hex')\n ELSE 'job_common'\n END;\n queue_created_on timestamptz;\n BEGIN\n\n WITH q as (\n INSERT INTO {{schema}}.queue (\n name,\n policy,\n retry_limit,\n retry_delay,\n retry_backoff,\n retry_delay_max,\n expire_seconds,\n retention_seconds,\n deletion_seconds,\n warning_queued,\n dead_letter,\n partition,\n table_name,\n heartbeat_seconds,\n notify,\n created_on,\n updated_on\n )\n VALUES (\n queue_name,\n options->>'policy',\n COALESCE((options->>'retryLimit')::int, 2),\n COALESCE((options->>'retryDelay')::int, 0),\n COALESCE((options->>'retryBackoff')::bool, false),\n (options->>'retryDelayMax')::int,\n COALESCE((options->>'expireInSeconds')::int, 900),\n COALESCE((options->>'retentionSeconds')::int, 1209600),\n COALESCE((options->>'deleteAfterSeconds')::int, 604800),\n COALESCE((options->>'warningQueueSize')::int, 0),\n options->>'deadLetter',\n COALESCE((options->>'partition')::bool, false),\n tablename,\n (options->>'heartbeatSeconds')::int,\n COALESCE((options->>'notify')::bool, false),\n {{schema}}.job_now(),\n {{schema}}.job_now()\n )\n ON CONFLICT DO NOTHING\n RETURNING created_on\n )\n SELECT created_on into queue_created_on from q;\n\n IF queue_created_on IS NULL OR options->>'partition' IS DISTINCT FROM 'true' THEN\n RETURN;\n END IF;\n\n EXECUTE format('CREATE TABLE {{schema}}.%I (LIKE {{schema}}.job INCLUDING DEFAULTS)', tablename);\n\n EXECUTE {{schema}}.job_table_format($cmd$ALTER TABLE {{schema}}.job ADD PRIMARY KEY (name, id)$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$ALTER TABLE {{schema}}.job ADD CONSTRAINT q_fkey FOREIGN KEY (name) REFERENCES {{schema}}.queue (name) ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$ALTER TABLE {{schema}}.job ADD CONSTRAINT dlq_fkey FOREIGN KEY (dead_letter) REFERENCES {{schema}}.queue (name) ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED$cmd$, tablename);\n\n EXECUTE {{schema}}.job_table_format($cmd$CREATE INDEX job_i11 ON {{schema}}.job (name, priority DESC, created_on, start_after) WHERE state < 'active' AND NOT blocked$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i4 ON {{schema}}.job (name, singleton_on, COALESCE(singleton_key, '')) WHERE state <> 'cancelled' AND singleton_on IS NOT NULL$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$CREATE INDEX job_i7 ON {{schema}}.job (name, group_id) WHERE state = 'active' AND group_id IS NOT NULL$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$CREATE INDEX job_i9 ON {{schema}}.job (name, id) WHERE blocking AND state = 'completed'$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$CREATE INDEX job_i12 ON {{schema}}.job (source_root_id) WHERE source_root_id IS NOT NULL$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i13 ON {{schema}}.job (name, singleton_key) WHERE state = 'created' AND upsert_by_key$cmd$, tablename);\n\n IF options->>'policy' = 'short' THEN\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i1 ON {{schema}}.job (name, COALESCE(singleton_key, '')) WHERE state = 'created' AND policy = 'short'$cmd$, tablename);\n ELSIF options->>'policy' = 'singleton' THEN\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i2 ON {{schema}}.job (name, COALESCE(singleton_key, '')) WHERE state = 'active' AND policy = 'singleton'$cmd$, tablename);\n ELSIF options->>'policy' = 'stately' THEN\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i3 ON {{schema}}.job (name, state, COALESCE(singleton_key, '')) WHERE state <= 'active' AND policy = 'stately'$cmd$, tablename);\n ELSIF options->>'policy' = 'exclusive' THEN\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i6 ON {{schema}}.job (name, COALESCE(singleton_key, '')) WHERE state <= 'active' AND policy = 'exclusive'$cmd$, tablename);\n ELSIF options->>'policy' = 'key_strict_fifo' THEN\n EXECUTE {{schema}}.job_table_format($cmd$CREATE UNIQUE INDEX job_i8 ON {{schema}}.job (name, singleton_key) WHERE state IN ('active', 'retry', 'failed') AND policy = 'key_strict_fifo'$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$CREATE INDEX job_i10 ON {{schema}}.job (name, singleton_key, state DESC, created_on, id) INCLUDE (start_after) WHERE state < 'active' AND NOT blocked AND policy = 'key_strict_fifo'$cmd$, tablename);\n EXECUTE {{schema}}.job_table_format($cmd$ALTER TABLE {{schema}}.job ADD CONSTRAINT job_key_strict_fifo_singleton_key_check CHECK (NOT (policy = 'key_strict_fifo' AND singleton_key IS NULL))$cmd$, tablename);\n END IF;\n\n EXECUTE format('ALTER TABLE {{schema}}.%I ADD CONSTRAINT cjc CHECK (name=%L)', tablename, queue_name);\n EXECUTE format('ALTER TABLE {{schema}}.job ATTACH PARTITION {{schema}}.%I FOR VALUES IN (%L)', tablename, queue_name);\n END;\n $function$\n"
1526
1546
  },
1527
1547
  {
1528
1548
  "name": "delete_queue",
@@ -2100,6 +2120,13 @@
2100
2120
  "notNull": false,
2101
2121
  "default": null
2102
2122
  },
2123
+ {
2124
+ "table": "job",
2125
+ "column": "upsert_by_key",
2126
+ "type": "boolean",
2127
+ "notNull": false,
2128
+ "default": null
2129
+ },
2103
2130
  {
2104
2131
  "table": "queue_stats",
2105
2132
  "column": "active_count",
@@ -2759,6 +2786,12 @@
2759
2786
  "valid": true,
2760
2787
  "def": "CREATE INDEX job_i12 ON {{schema}}.job USING btree (source_root_id) WHERE (source_root_id IS NOT NULL)"
2761
2788
  },
2789
+ {
2790
+ "name": "job_i13",
2791
+ "table": "job",
2792
+ "valid": true,
2793
+ "def": "CREATE UNIQUE INDEX job_i13 ON {{schema}}.job USING btree (name, singleton_key) WHERE ((state = 'created'::{{schema}}.job_state) AND upsert_by_key)"
2794
+ },
2762
2795
  {
2763
2796
  "name": "job_i2",
2764
2797
  "table": "job",
@@ -76,6 +76,9 @@ declare class Timekeeper extends EventEmitter implements types.EventsMixin {
76
76
  * labels every row from its default. Either way the row reads fine and never fires again. So when
77
77
  * an expression cannot be read the way the column says, and is written the other way, it is read
78
78
  * the way it is written: one regex, on a path that was already about to give up.
79
+ *
80
+ * An empty expression is refused first, as schedule() refuses it. cron-parser would read one as
81
+ * every minute, and a row stored before schedule() checked for it would keep firing that often.
79
82
  */
80
83
  private dueOccurrences;
81
84
  /**
@@ -170,7 +173,7 @@ declare class Timekeeper extends EventEmitter implements types.EventsMixin {
170
173
  */
171
174
  previewSchedule(cron: string, options?: types.PreviewScheduleOptions): Date[];
172
175
  schedule(name: string, cron: string, data?: unknown, options?: types.ScheduleOptions): Promise<void>;
173
- unschedule(name: string, key?: string): Promise<void>;
176
+ unschedule(name: string, key?: string, options?: types.ConnectionOptions): Promise<void>;
174
177
  }
175
178
  export default Timekeeper;
176
179
  //# sourceMappingURL=timekeeper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"timekeeper.d.ts","sourceRoot":"","sources":["../src/timekeeper.ts"],"names":[],"mappings":"AAEA,OAAO,YAAY,MAAM,aAAa,CAAA;AAItC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAKvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAGnC,eAAO,MAAM,MAAM;;CAElB,CAAA;AAwBD,kGAAkG;AAClG,eAAO,MAAM,iBAAiB,OAAO,CAAA;AA8MrC,cAAM,UAAW,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;IAChE,EAAE,EAAE,KAAK,CAAC,SAAS,CAAA;IACnB,MAAM,EAAE,KAAK,CAAC,0BAA0B,CAAA;IACxC,OAAO,EAAE,OAAO,CAAA;IAEhB,OAAO,CAAC,OAAO,CAAO;IACtB,OAAO,CAAC,gBAAgB,CAA+B;IACvD,OAAO,CAAC,gBAAgB,CAAqC;IAC7D,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,aAAa,CAAQ;IAS7B,OAAO,CAAC,eAAe,CAAoB;IAE3C,SAAS,SAAI;IACb,MAAM;;;;MAAS;gBAEF,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAQ5F,IAAI,YAAY,IAAK,OAAO,CAE3B;IAED,IAAI,WAAW,IAAK,OAAO,CAE1B;IAMD,OAAO,KAAK,YAAY,GAEvB;IAED,OAAO,KAAK,cAAc,GASzB;IAEK,KAAK;IA4BL,IAAI;IAmCV,OAAO,CAAC,mBAAmB;IAUrB,cAAc;IA8Cd,MAAM;IAuDZ;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,eAAe;IAQvB;;;;;OAKG;IACG,IAAI,CAAE,WAAW,GAAE,OAAc;IAoIvC,YAAY,CAAE,UAAU,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,GAAE,KAAK,CAAC,YAAwC;IAIlG;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,cAAc;IAgBtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACH,OAAO,CAAC,gBAAgB;IAgBxB;;;;;;;OAOG;IACH,OAAO,CAAC,sBAAsB;IAgB9B;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,iBAAiB;YAQX,QAAQ;YAsDR,aAAa;IAerB,YAAY,CAAE,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;IAiBrE,WAAW,CAAE,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,GAAG,IAAI,CAAC;IAe1E;;;;;;;OAOG;IACH,OAAO,CAAC,gBAAgB;IAYxB;;;;;;;;;;;;;;;;OAgBG;IACH,eAAe,CAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,KAAK,CAAC,sBAA2B,GAAG,IAAI,EAAE;IA0C5E,QAAQ,CAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,KAAK,CAAC,eAAoB,GAAG,OAAO,CAAC,IAAI,CAAC;IA6CzG,UAAU,CAAE,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,OAAO,CAAC,IAAI,CAAC;CAIzD;AAED,eAAe,UAAU,CAAA"}
1
+ {"version":3,"file":"timekeeper.d.ts","sourceRoot":"","sources":["../src/timekeeper.ts"],"names":[],"mappings":"AAEA,OAAO,YAAY,MAAM,aAAa,CAAA;AAItC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAKvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAGnC,eAAO,MAAM,MAAM;;CAElB,CAAA;AAwBD,kGAAkG;AAClG,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAuNrC,cAAM,UAAW,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;IAChE,EAAE,EAAE,KAAK,CAAC,SAAS,CAAA;IACnB,MAAM,EAAE,KAAK,CAAC,0BAA0B,CAAA;IACxC,OAAO,EAAE,OAAO,CAAA;IAEhB,OAAO,CAAC,OAAO,CAAO;IACtB,OAAO,CAAC,gBAAgB,CAA+B;IACvD,OAAO,CAAC,gBAAgB,CAAqC;IAC7D,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,aAAa,CAAQ;IAS7B,OAAO,CAAC,eAAe,CAAoB;IAE3C,SAAS,SAAI;IACb,MAAM;;;;MAAS;gBAEF,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAQ5F,IAAI,YAAY,IAAK,OAAO,CAE3B;IAED,IAAI,WAAW,IAAK,OAAO,CAE1B;IAMD,OAAO,KAAK,YAAY,GAEvB;IAED,OAAO,KAAK,cAAc,GASzB;IAEK,KAAK;IA4BL,IAAI;IAmCV,OAAO,CAAC,mBAAmB;IAUrB,cAAc;IA8Cd,MAAM;IAuDZ;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,eAAe;IAQvB;;;;;OAKG;IACG,IAAI,CAAE,WAAW,GAAE,OAAc;IAoIvC,YAAY,CAAE,UAAU,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,GAAE,KAAK,CAAC,YAAwC;IAIlG;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,cAAc;IAkBtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACH,OAAO,CAAC,gBAAgB;IAgBxB;;;;;;;OAOG;IACH,OAAO,CAAC,sBAAsB;IAgB9B;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,iBAAiB;YAQX,QAAQ;YAwDR,aAAa;IAerB,YAAY,CAAE,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;IAiBrE,WAAW,CAAE,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,GAAG,IAAI,CAAC;IAe1E;;;;;;;OAOG;IACH,OAAO,CAAC,gBAAgB;IAcxB;;;;;;;;;;;;;;;;OAgBG;IACH,eAAe,CAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,KAAK,CAAC,sBAA2B,GAAG,IAAI,EAAE;IA0C5E,QAAQ,CAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,KAAK,CAAC,eAAoB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiDzG,UAAU,CAAE,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,EAAE,OAAO,GAAE,KAAK,CAAC,iBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC;CAIhG;AAED,eAAe,UAAU,CAAA"}
@@ -178,11 +178,18 @@ function parseRecurrence(cron, tz, currentDate) {
178
178
  assertTimezone(tz);
179
179
  return CronExpressionParser.parse(cron, { tz, strict: false, currentDate });
180
180
  }
181
+ // cron-parser reads an empty expression, and undefined or null, as `* * * * *`, and those are nearly
182
+ // always a missing value, not a request to run every minute. Checked before isRrule(), which reads a
183
+ // non-string as its string form and would send something like ['FREQ=DAILY'] down the rule path.
184
+ function assertExpression(expression) {
185
+ assert(typeof expression === 'string' && expression.trim() !== '', 'cron expression must be a non-empty string');
186
+ }
181
187
  /**
182
188
  * Validates a recurrence in whichever of the two formats it is written, so previewSchedule() and
183
189
  * schedule() reject exactly the same expressions.
184
190
  */
185
191
  function assertRecurrence(expression, tz, now) {
192
+ assertExpression(expression);
186
193
  if (isRrule(expression)) {
187
194
  assertRrule(expression, tz);
188
195
  }
@@ -542,8 +549,12 @@ class Timekeeper extends EventEmitter {
542
549
  * labels every row from its default. Either way the row reads fine and never fires again. So when
543
550
  * an expression cannot be read the way the column says, and is written the other way, it is read
544
551
  * the way it is written: one regex, on a path that was already about to give up.
552
+ *
553
+ * An empty expression is refused first, as schedule() refuses it. cron-parser would read one as
554
+ * every minute, and a row stored before schedule() checked for it would keep firing that often.
545
555
  */
546
556
  dueOccurrences(expression, kind, tz, databaseTime = this.databaseTime) {
557
+ assertExpression(expression);
547
558
  try {
548
559
  return { kind, occurrences: this.readOccurrences(expression, kind, tz, databaseTime) };
549
560
  }
@@ -670,7 +681,9 @@ class Timekeeper extends EventEmitter {
670
681
  // key and slot are the pass's own bookkeeping, read below rather than sent: send() takes the
671
682
  // request the schedule row described and nothing else.
672
683
  const { key, slot, ...request } = data;
673
- return await this.manager.send(request);
684
+ // A row written before 12.37.0 may still carry `db` from schedule(); it is never a send option here.
685
+ const { db, ...options } = request.options ?? {};
686
+ return await this.manager.send({ ...request, options });
674
687
  }));
675
688
  // Keyed on (name, key) so a batch holding more than one occurrence of the same schedule
676
689
  // resolves to its latest. Feeding several to the UPDATE would let postgres pick any of the
@@ -756,6 +769,7 @@ class Timekeeper extends EventEmitter {
756
769
  * occurrence after the last one handed back.
757
770
  */
758
771
  occurrenceWalker(expression, tz, from) {
772
+ assertExpression(expression);
759
773
  if (isRrule(expression)) {
760
774
  assertRrule(expression, tz);
761
775
  return rruleWalker(expression, tz, from);
@@ -810,9 +824,12 @@ class Timekeeper extends EventEmitter {
810
824
  return occurrences;
811
825
  }
812
826
  async schedule(name, cron, data, options = {}) {
827
+ // `db` is how this call runs, not part of the schedule: it comes out first so the options blob
828
+ // stored on the row, and later handed to send() by the cron pass, never carries a connection.
829
+ const { db, ...persisted } = options;
813
830
  // `missed` comes out with tz and key: it tells the pass what to do about a gap and is no more a
814
831
  // send option than they are, so the send-option check below is not handed it.
815
- const { tz: requestedTz, key = '', missed, ...rest } = options;
832
+ const { tz: requestedTz, key = '', missed, ...rest } = persisted;
816
833
  // Any falsy zone is "none specified", not a zone to be judged: a destructuring default only
817
834
  // covers `undefined`, and a value threaded out of a config object or read back off the nullable
818
835
  // timezone column arrives as null. Coalescing it here is what keeps the two engines from
@@ -838,7 +855,7 @@ class Timekeeper extends EventEmitter {
838
855
  assertMissedPolicy(missed);
839
856
  try {
840
857
  const sql = plans.schedule(this.config.schema);
841
- await this.db.executeSql(sql, [name, key, kind, cron, tz, data, options]);
858
+ await (db || this.db).executeSql(sql, [name, key, kind, cron, tz, data, persisted]);
842
859
  }
843
860
  catch (err) {
844
861
  if (err.message.includes('foreign key')) {
@@ -847,9 +864,9 @@ class Timekeeper extends EventEmitter {
847
864
  throw err;
848
865
  }
849
866
  }
850
- async unschedule(name, key = '') {
867
+ async unschedule(name, key = '', options = {}) {
851
868
  const sql = plans.unschedule(this.config.schema);
852
- await this.db.executeSql(sql, [name, key]);
869
+ await (options.db || this.db).executeSql(sql, [name, key]);
853
870
  }
854
871
  }
855
872
  export default Timekeeper;