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/README.md +2 -2
- package/dist/attorney.d.ts.map +1 -1
- package/dist/attorney.js +5 -0
- package/dist/cli.js +8 -2
- package/dist/contractor.d.ts +1 -0
- package/dist/contractor.d.ts.map +1 -1
- package/dist/contractor.js +4 -0
- package/dist/db.js +3 -3
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +69 -10
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +112 -15
- package/dist/migrationStore.d.ts.map +1 -1
- package/dist/migrationStore.js +127 -0
- package/dist/plans.d.ts +8 -3
- package/dist/plans.d.ts.map +1 -1
- package/dist/plans.js +134 -40
- package/dist/registrar.d.ts +1 -1
- package/dist/registrar.d.ts.map +1 -1
- package/dist/registrar.js +6 -1
- package/dist/schema.json +35 -2
- package/dist/timekeeper.d.ts +4 -1
- package/dist/timekeeper.d.ts.map +1 -1
- package/dist/timekeeper.js +22 -5
- package/dist/types.d.ts +16 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/worker.d.ts +1 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +2 -0
- package/package.json +2 -2
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
|
-
|
|
1139
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
2171
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
4092
|
-
//
|
|
4093
|
-
//
|
|
4094
|
-
//
|
|
4095
|
-
//
|
|
4096
|
-
//
|
|
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;
|
package/dist/registrar.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/registrar.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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":
|
|
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",
|
package/dist/timekeeper.d.ts
CHANGED
|
@@ -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
|
package/dist/timekeeper.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/timekeeper.js
CHANGED
|
@@ -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
|
-
|
|
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 } =
|
|
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,
|
|
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;
|