@byline/db-mysql 4.14.1 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/database/schema/common.js +1 -1
- package/dist/database/schema/index.d.ts +575 -0
- package/dist/database/schema/index.js +81 -1
- package/dist/database/schema/schema-pins.test.node.js +45 -0
- package/dist/index.js +3 -0
- package/dist/modules/scheduler/scheduler-store.d.ts +13 -0
- package/dist/modules/scheduler/scheduler-store.js +237 -0
- package/dist/modules/storage/publish-schedules.d.ts +85 -0
- package/dist/modules/storage/publish-schedules.js +406 -0
- package/dist/modules/storage/storage-commands.d.ts +2 -0
- package/dist/modules/storage/storage-commands.js +3 -0
- package/dist/modules/storage/storage-queries.d.ts +2 -0
- package/dist/modules/storage/storage-queries.js +3 -0
- package/package.json +6 -6
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { eq, relations, sql } from 'drizzle-orm';
|
|
9
|
-
import { bigint, boolean, date, datetime, decimal, float, foreignKey, index, int, json, mysqlTable, mysqlView, primaryKey, text, time, unique, varchar, } from 'drizzle-orm/mysql-core';
|
|
9
|
+
import { bigint, boolean, check, date, datetime, decimal, float, foreignKey, index, int, json, mysqlTable, mysqlView, primaryKey, text, time, unique, varchar, } from 'drizzle-orm/mysql-core';
|
|
10
10
|
import { createdAt, timestamps, uuidChar, varcharByteSorted, varcharCaseSensitive, } from './common.js';
|
|
11
11
|
// Every foreign key below is declared with the table-level `foreignKey()`
|
|
12
12
|
// builder and an explicit, short `fk_<table>_<column>` name, rather than
|
|
@@ -115,6 +115,55 @@ export const documentVersions = mysqlTable('byline_document_versions', {
|
|
|
115
115
|
// Ensure logical document belongs to only one collection
|
|
116
116
|
index('idx_documents_document_collection').on(table.document_id, table.collection_id),
|
|
117
117
|
]);
|
|
118
|
+
// Scheduled publication — one active intent row per logical document.
|
|
119
|
+
// This is document lifecycle metadata, not authored/versioned content. The
|
|
120
|
+
// target version is pinned until a later content write suspends the row for
|
|
121
|
+
// explicit re-confirmation. See specs/2026-08-22-scheduled-publish.md.
|
|
122
|
+
export const documentPublishSchedules = mysqlTable('byline_document_publish_schedules', {
|
|
123
|
+
document_id: uuidChar('document_id').primaryKey(),
|
|
124
|
+
collection_id: uuidChar('collection_id').notNull(),
|
|
125
|
+
target_version_id: uuidChar('target_version_id').notNull(),
|
|
126
|
+
publish_at: datetime('publish_at', { fsp: 6 }).notNull(),
|
|
127
|
+
state: varchar('state', { length: 32 }).notNull().default('armed'),
|
|
128
|
+
suspended_at: datetime('suspended_at', { fsp: 6 }),
|
|
129
|
+
suspended_reason: varchar('suspended_reason', { length: 32 }),
|
|
130
|
+
// Historical and current authorizers are deliberately not foreign keys:
|
|
131
|
+
// deleting an admin account must not revoke an already-authorized schedule.
|
|
132
|
+
scheduled_by: uuidChar('scheduled_by'),
|
|
133
|
+
last_authorized_by: uuidChar('last_authorized_by'),
|
|
134
|
+
last_authorized_at: datetime('last_authorized_at', { fsp: 6 }).notNull(),
|
|
135
|
+
scheduled_at: datetime('scheduled_at', { fsp: 6 }).notNull().default(sql `CURRENT_TIMESTAMP(6)`),
|
|
136
|
+
updated_at: datetime('updated_at', { fsp: 6 }).notNull().default(sql `CURRENT_TIMESTAMP(6)`),
|
|
137
|
+
execution_token: uuidChar('execution_token'),
|
|
138
|
+
execution_expires_at: datetime('execution_expires_at', { fsp: 6 }),
|
|
139
|
+
last_attempt_at: datetime('last_attempt_at', { fsp: 6 }),
|
|
140
|
+
next_attempt_at: datetime('next_attempt_at', { fsp: 6 }).notNull(),
|
|
141
|
+
attempt_count: int('attempt_count').notNull().default(0),
|
|
142
|
+
// Sanitized by the storage command and capped at 2 KiB; never stores a
|
|
143
|
+
// stack or document content.
|
|
144
|
+
last_error: text('last_error'),
|
|
145
|
+
}, (table) => [
|
|
146
|
+
foreignKey({
|
|
147
|
+
name: 'fk_publish_schedules_document',
|
|
148
|
+
columns: [table.document_id],
|
|
149
|
+
foreignColumns: [documents.id],
|
|
150
|
+
}).onDelete('cascade'),
|
|
151
|
+
foreignKey({
|
|
152
|
+
name: 'fk_publish_schedules_collection',
|
|
153
|
+
columns: [table.collection_id],
|
|
154
|
+
foreignColumns: [collections.id],
|
|
155
|
+
}).onDelete('cascade'),
|
|
156
|
+
foreignKey({
|
|
157
|
+
name: 'fk_publish_schedules_target_version',
|
|
158
|
+
columns: [table.target_version_id],
|
|
159
|
+
foreignColumns: [documentVersions.id],
|
|
160
|
+
}).onDelete('cascade'),
|
|
161
|
+
check('check_publish_schedules_state', sql `${table.state} IN ('armed', 'needs_reconfirm')`),
|
|
162
|
+
check('check_publish_schedules_suspended_reason', sql `${table.suspended_reason} IS NULL OR ${table.suspended_reason} = 'content_edited'`),
|
|
163
|
+
// MySQL has no partial indexes, so state is the leading discriminator.
|
|
164
|
+
index('idx_document_publish_schedules_due').on(table.state, table.next_attempt_at, table.publish_at),
|
|
165
|
+
index('idx_document_publish_schedules_execution_expiry').on(table.execution_expires_at),
|
|
166
|
+
]);
|
|
118
167
|
// Document paths — one row per (logical document, content locale).
|
|
119
168
|
// Promotes `path` out of the version row so per-collection uniqueness can
|
|
120
169
|
// be enforced at the DB layer without colliding with the sticky
|
|
@@ -894,6 +943,37 @@ export const auditLog = mysqlTable('byline_audit_log', {
|
|
|
894
943
|
index('idx_audit_log_action').on(table.action, table.id),
|
|
895
944
|
]);
|
|
896
945
|
// ---------------------------------------------------------------------------
|
|
946
|
+
// Recurring-task scheduler — byline_recurring_tasks
|
|
947
|
+
// ---------------------------------------------------------------------------
|
|
948
|
+
// Recurring-task scheduler (specs/2026-08-22-scheduler.md).
|
|
949
|
+
// Code-registered definitions are authoritative; rows for definitions removed
|
|
950
|
+
// from code are retained as dormant history and never executed.
|
|
951
|
+
export const recurringTasks = mysqlTable('byline_recurring_tasks', {
|
|
952
|
+
name: varchar('name', { length: 255 }).primaryKey(),
|
|
953
|
+
// bigint: MySQL INT tops out at ~24.9 days in milliseconds, and a 30-day
|
|
954
|
+
// interval is a legitimate configuration. `mode: 'number'` is safe because
|
|
955
|
+
// boot-time validation guarantees every millisecond value is a JS safe integer.
|
|
956
|
+
interval_ms: bigint('interval_ms', { mode: 'number' }).notNull(),
|
|
957
|
+
next_run_at: datetime('next_run_at', { fsp: 6 }).notNull(),
|
|
958
|
+
// A token unique to one claim — not a stable machine id. Every health and
|
|
959
|
+
// schedule write is conditional on it, so a slow runner whose lease expired
|
|
960
|
+
// cannot overwrite a newer run.
|
|
961
|
+
lease_token: uuidChar('lease_token'),
|
|
962
|
+
// Bounded, non-secret diagnostic label (machine id + pid). Correctness never
|
|
963
|
+
// depends on owner uniqueness.
|
|
964
|
+
lease_owner: varchar('lease_owner', { length: 255 }),
|
|
965
|
+
lease_expires_at: datetime('lease_expires_at', { fsp: 6 }),
|
|
966
|
+
last_started_at: datetime('last_started_at', { fsp: 6 }),
|
|
967
|
+
last_succeeded_at: datetime('last_succeeded_at', { fsp: 6 }),
|
|
968
|
+
last_failed_at: datetime('last_failed_at', { fsp: 6 }),
|
|
969
|
+
last_duration_ms: bigint('last_duration_ms', { mode: 'number' }),
|
|
970
|
+
consecutive_failures: int('consecutive_failures').notNull().default(0),
|
|
971
|
+
last_status: varchar('last_status', { length: 32 }).notNull().default('never_run'),
|
|
972
|
+
// Sanitized message, capped at 2 KiB by the store. Full stacks go to the logger.
|
|
973
|
+
last_error: text('last_error'),
|
|
974
|
+
...timestamps,
|
|
975
|
+
});
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
897
977
|
// Auth schema — byline_admin_users, byline_admin_roles, etc.
|
|
898
978
|
// See ./auth.ts for definitions and rationale.
|
|
899
979
|
// ---------------------------------------------------------------------------
|
|
@@ -418,3 +418,48 @@ describe('schema pins — document-paths case-sensitive collation (project-owner
|
|
|
418
418
|
expect(pathColumn?.getSQLType()).toBe('varchar(255) COLLATE utf8mb4_bin');
|
|
419
419
|
});
|
|
420
420
|
});
|
|
421
|
+
describe('schema pins — scheduled publication', () => {
|
|
422
|
+
const cfg = getTableConfig(coreSchema.documentPublishSchedules);
|
|
423
|
+
it('pins one row per document and exactly the three lifecycle ownership foreign keys', () => {
|
|
424
|
+
const documentId = cfg.columns.find((column) => column.name === 'document_id');
|
|
425
|
+
expect(documentId?.primary).toBe(true);
|
|
426
|
+
const references = cfg.foreignKeys.map((foreignKey) => ({
|
|
427
|
+
name: foreignKey.getName(),
|
|
428
|
+
local: foreignKey.reference().columns.map((column) => column.name),
|
|
429
|
+
onDelete: foreignKey.onDelete,
|
|
430
|
+
}));
|
|
431
|
+
expect(references).toEqual([
|
|
432
|
+
{
|
|
433
|
+
name: 'fk_publish_schedules_document',
|
|
434
|
+
local: ['document_id'],
|
|
435
|
+
onDelete: 'cascade',
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
name: 'fk_publish_schedules_collection',
|
|
439
|
+
local: ['collection_id'],
|
|
440
|
+
onDelete: 'cascade',
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: 'fk_publish_schedules_target_version',
|
|
444
|
+
local: ['target_version_id'],
|
|
445
|
+
onDelete: 'cascade',
|
|
446
|
+
},
|
|
447
|
+
]);
|
|
448
|
+
});
|
|
449
|
+
it('pins the bounded state and suspension-reason constraints', () => {
|
|
450
|
+
expect(cfg.checks.map((constraint) => constraint.name).sort()).toEqual([
|
|
451
|
+
'check_publish_schedules_state',
|
|
452
|
+
'check_publish_schedules_suspended_reason',
|
|
453
|
+
]);
|
|
454
|
+
});
|
|
455
|
+
it('pins state as the MySQL due-index discriminator and execution-expiry recovery', () => {
|
|
456
|
+
const due = cfg.indexes.find((candidate) => candidate.config.name === 'idx_document_publish_schedules_due');
|
|
457
|
+
expect(due?.config.columns.map((column) => ('name' in column ? column.name : null))).toEqual([
|
|
458
|
+
'state',
|
|
459
|
+
'next_attempt_at',
|
|
460
|
+
'publish_at',
|
|
461
|
+
]);
|
|
462
|
+
const expiry = cfg.indexes.find((candidate) => candidate.config.name === 'idx_document_publish_schedules_execution_expiry');
|
|
463
|
+
expect(expiry?.config.columns.map((column) => ('name' in column ? column.name : null))).toEqual(['execution_expires_at']);
|
|
464
|
+
});
|
|
465
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { DBManagerImpl, TXManagerImpl } from './lib/db-manager.js';
|
|
|
13
13
|
import { createAuditCommands } from './modules/audit/audit-commands.js';
|
|
14
14
|
import { createAuditQueries } from './modules/audit/audit-queries.js';
|
|
15
15
|
import { createCounterCommands } from './modules/counters/counters-commands.js';
|
|
16
|
+
import { createSchedulerStore } from './modules/scheduler/scheduler-store.js';
|
|
16
17
|
import { classifyError } from './modules/storage/classify-error.js';
|
|
17
18
|
import { createCommandBuilders } from './modules/storage/storage-commands.js';
|
|
18
19
|
import { createQueryBuilders } from './modules/storage/storage-queries.js';
|
|
@@ -85,6 +86,7 @@ export const mysqlAdapter = ({ connectionString, collections, defaultContentLoca
|
|
|
85
86
|
const counterCommands = createCounterCommands(pool);
|
|
86
87
|
const auditCommands = createAuditCommands(dbManager);
|
|
87
88
|
const auditQueries = createAuditQueries(db);
|
|
89
|
+
const schedulerStore = createSchedulerStore(db);
|
|
88
90
|
// Boot check: run lazily on the pool's first physical connection rather
|
|
89
91
|
// than eagerly here, because `mysqlAdapter` is synchronous (mirroring
|
|
90
92
|
// `pgAdapter`) and cannot await a round trip before returning. mysql2
|
|
@@ -144,6 +146,7 @@ export const mysqlAdapter = ({ connectionString, collections, defaultContentLoca
|
|
|
144
146
|
audit: auditQueries,
|
|
145
147
|
},
|
|
146
148
|
withTransaction: (fn) => txManager.withTransaction(fn),
|
|
149
|
+
scheduler: schedulerStore,
|
|
147
150
|
classifyError,
|
|
148
151
|
drizzle: db,
|
|
149
152
|
pool,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { ISchedulerStore } from '@byline/core';
|
|
9
|
+
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
|
10
|
+
import type * as schema from '../../database/schema/index.js';
|
|
11
|
+
type DatabaseConnection = MySql2Database<typeof schema>;
|
|
12
|
+
export declare function createSchedulerStore(db: DatabaseConnection): ISchedulerStore;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* MySQL implementation of `ISchedulerStore` (specs/2026-08-22-scheduler.md).
|
|
10
|
+
* Every due and lease comparison uses database UTC time, and every write after
|
|
11
|
+
* a claim is fenced by the claim's token.
|
|
12
|
+
*
|
|
13
|
+
* MySQL has no `UPDATE ... RETURNING`, so `claim()` locks the eligible row,
|
|
14
|
+
* updates it, and returns the pre-claim schedule from one READ COMMITTED
|
|
15
|
+
* transaction. The transaction is deliberately store-owned rather than ambient:
|
|
16
|
+
* scheduler claims must not inherit a caller's longer transaction boundary.
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { MAX_BACKOFF_MS } from '@byline/core/scheduler';
|
|
20
|
+
import { sql } from 'drizzle-orm';
|
|
21
|
+
import { affectedRowCount, toDate } from '../storage/storage-utils.js';
|
|
22
|
+
/** Doc comment on `ISchedulerStore.fail`: 2048 characters, never a stack trace. */
|
|
23
|
+
const LAST_ERROR_MAX_LENGTH = 2048;
|
|
24
|
+
/** Doc comment on `ISchedulerStore.health`: `lease_owner` capped at 255 characters. */
|
|
25
|
+
const LEASE_OWNER_MAX_LENGTH = 255;
|
|
26
|
+
const MAX_BACKOFF_SECONDS = MAX_BACKOFF_MS / 1000;
|
|
27
|
+
function requiredDate(value, context) {
|
|
28
|
+
const date = toDate(value, context);
|
|
29
|
+
if (date === null) {
|
|
30
|
+
throw new Error(`scheduler store: ${context} unexpectedly returned null`);
|
|
31
|
+
}
|
|
32
|
+
return date;
|
|
33
|
+
}
|
|
34
|
+
class SchedulerStore {
|
|
35
|
+
db;
|
|
36
|
+
constructor(db) {
|
|
37
|
+
this.db = db;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* One conflict-tolerant upsert per definition. `interval_ms` always follows
|
|
41
|
+
* code, while an unleased `next_run_at` is only pulled earlier when the new
|
|
42
|
+
* interval would otherwise leave the task waiting too long.
|
|
43
|
+
*/
|
|
44
|
+
async reconcile(tasks) {
|
|
45
|
+
for (const task of tasks) {
|
|
46
|
+
await this.db.execute(sql `
|
|
47
|
+
INSERT INTO byline_recurring_tasks (name, interval_ms, next_run_at, last_status)
|
|
48
|
+
VALUES (
|
|
49
|
+
${task.name},
|
|
50
|
+
${task.intervalMs},
|
|
51
|
+
TIMESTAMPADD(MICROSECOND, ${task.intervalMs} * 1000, CURRENT_TIMESTAMP(6)),
|
|
52
|
+
'never_run'
|
|
53
|
+
)
|
|
54
|
+
ON DUPLICATE KEY UPDATE
|
|
55
|
+
next_run_at = CASE
|
|
56
|
+
WHEN lease_token IS NULL
|
|
57
|
+
AND next_run_at > TIMESTAMPADD(
|
|
58
|
+
MICROSECOND,
|
|
59
|
+
${task.intervalMs} * 1000,
|
|
60
|
+
CURRENT_TIMESTAMP(6)
|
|
61
|
+
)
|
|
62
|
+
THEN TIMESTAMPADD(MICROSECOND, ${task.intervalMs} * 1000, CURRENT_TIMESTAMP(6))
|
|
63
|
+
ELSE next_run_at
|
|
64
|
+
END,
|
|
65
|
+
interval_ms = ${task.intervalMs},
|
|
66
|
+
updated_at = CURRENT_TIMESTAMP(6)
|
|
67
|
+
`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Atomic claim through a locking read and fenced update on one physical
|
|
72
|
+
* connection. Under READ COMMITTED, contenders blocked on `FOR UPDATE`
|
|
73
|
+
* re-check the eligibility predicate after the winner commits and return
|
|
74
|
+
* null instead of observing the stale due row.
|
|
75
|
+
*/
|
|
76
|
+
async claim(params) {
|
|
77
|
+
return this.db.transaction(async (tx) => {
|
|
78
|
+
const selected = (await tx.execute(sql `
|
|
79
|
+
SELECT
|
|
80
|
+
name,
|
|
81
|
+
next_run_at AS scheduled_for,
|
|
82
|
+
CURRENT_TIMESTAMP(6) AS database_now,
|
|
83
|
+
(lease_expires_at IS NOT NULL) AS recovered_expired_lease
|
|
84
|
+
FROM byline_recurring_tasks
|
|
85
|
+
WHERE name = ${params.name}
|
|
86
|
+
AND next_run_at <= CURRENT_TIMESTAMP(6)
|
|
87
|
+
AND (lease_expires_at IS NULL OR lease_expires_at <= CURRENT_TIMESTAMP(6))
|
|
88
|
+
FOR UPDATE
|
|
89
|
+
`));
|
|
90
|
+
const row = selected[0][0];
|
|
91
|
+
if (row === undefined)
|
|
92
|
+
return null;
|
|
93
|
+
const leaseToken = randomUUID();
|
|
94
|
+
const result = await tx.execute(sql `
|
|
95
|
+
UPDATE byline_recurring_tasks SET
|
|
96
|
+
lease_token = ${leaseToken},
|
|
97
|
+
lease_owner = ${params.owner.slice(0, LEASE_OWNER_MAX_LENGTH)},
|
|
98
|
+
lease_expires_at = TIMESTAMPADD(
|
|
99
|
+
MICROSECOND,
|
|
100
|
+
${params.leaseMs} * 1000,
|
|
101
|
+
CURRENT_TIMESTAMP(6)
|
|
102
|
+
),
|
|
103
|
+
last_started_at = CURRENT_TIMESTAMP(6),
|
|
104
|
+
last_status = 'running',
|
|
105
|
+
updated_at = CURRENT_TIMESTAMP(6)
|
|
106
|
+
WHERE name = ${params.name}
|
|
107
|
+
`);
|
|
108
|
+
if (affectedRowCount(result) !== 1) {
|
|
109
|
+
throw new Error(`scheduler store: locked claim row '${params.name}' disappeared`);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
name: row.name,
|
|
113
|
+
leaseToken,
|
|
114
|
+
scheduledFor: requiredDate(row.scheduled_for, 'scheduled_for'),
|
|
115
|
+
databaseNow: requiredDate(row.database_now, 'database_now'),
|
|
116
|
+
recoveredExpiredLease: Boolean(row.recovered_expired_lease),
|
|
117
|
+
};
|
|
118
|
+
}, { isolationLevel: 'read committed' });
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Do not add an expiry predicate: expiry makes a row claimable but does not
|
|
122
|
+
* invalidate the current token until another claimant replaces it.
|
|
123
|
+
*/
|
|
124
|
+
async renew(params) {
|
|
125
|
+
const result = await this.db.execute(sql `
|
|
126
|
+
UPDATE byline_recurring_tasks SET
|
|
127
|
+
lease_expires_at = TIMESTAMPADD(
|
|
128
|
+
MICROSECOND,
|
|
129
|
+
${params.leaseMs} * 1000,
|
|
130
|
+
CURRENT_TIMESTAMP(6)
|
|
131
|
+
),
|
|
132
|
+
updated_at = CURRENT_TIMESTAMP(6)
|
|
133
|
+
WHERE name = ${params.name} AND lease_token = ${params.leaseToken}
|
|
134
|
+
`);
|
|
135
|
+
return affectedRowCount(result) === 1;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Complete a token-matched run and schedule from the persisted cadence.
|
|
139
|
+
* `interval_ms` is read but not assigned here, so assignment order is
|
|
140
|
+
* currently irrelevant. If a future change adds it to this SET list, keep
|
|
141
|
+
* that assignment after `next_run_at` so the persisted pre-update cadence is
|
|
142
|
+
* retained; see `fail()` for MySQL's left-to-right assignment rule.
|
|
143
|
+
*/
|
|
144
|
+
async complete(params) {
|
|
145
|
+
const nextRunAt = params.workRemaining
|
|
146
|
+
? sql `CURRENT_TIMESTAMP(6)`
|
|
147
|
+
: sql `TIMESTAMPADD(MICROSECOND, interval_ms * 1000, CURRENT_TIMESTAMP(6))`;
|
|
148
|
+
const result = await this.db.execute(sql `
|
|
149
|
+
UPDATE byline_recurring_tasks SET
|
|
150
|
+
last_succeeded_at = CURRENT_TIMESTAMP(6),
|
|
151
|
+
last_status = 'succeeded',
|
|
152
|
+
last_duration_ms = ${params.durationMs},
|
|
153
|
+
consecutive_failures = 0,
|
|
154
|
+
last_error = NULL,
|
|
155
|
+
lease_token = NULL,
|
|
156
|
+
lease_owner = NULL,
|
|
157
|
+
lease_expires_at = NULL,
|
|
158
|
+
next_run_at = ${nextRunAt},
|
|
159
|
+
updated_at = CURRENT_TIMESTAMP(6)
|
|
160
|
+
WHERE name = ${params.name} AND lease_token = ${params.leaseToken}
|
|
161
|
+
`);
|
|
162
|
+
return affectedRowCount(result) === 1;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Record a token-matched failure with 1, 2, 4, 8, then 15 minute backoff.
|
|
166
|
+
* MySQL evaluates single-table UPDATE assignments from left to right, so
|
|
167
|
+
* `next_run_at` intentionally reads the old `consecutive_failures` before
|
|
168
|
+
* the following assignment increments it.
|
|
169
|
+
*/
|
|
170
|
+
async fail(params) {
|
|
171
|
+
const result = await this.db.execute(sql `
|
|
172
|
+
UPDATE byline_recurring_tasks SET
|
|
173
|
+
last_failed_at = CURRENT_TIMESTAMP(6),
|
|
174
|
+
last_status = 'failed',
|
|
175
|
+
last_duration_ms = ${params.durationMs},
|
|
176
|
+
last_error = ${params.error.slice(0, LAST_ERROR_MAX_LENGTH)},
|
|
177
|
+
lease_token = NULL,
|
|
178
|
+
lease_owner = NULL,
|
|
179
|
+
lease_expires_at = NULL,
|
|
180
|
+
next_run_at = TIMESTAMPADD(
|
|
181
|
+
SECOND,
|
|
182
|
+
LEAST(
|
|
183
|
+
60 * CAST(POWER(2, LEAST(consecutive_failures, 4)) AS UNSIGNED),
|
|
184
|
+
${MAX_BACKOFF_SECONDS}
|
|
185
|
+
),
|
|
186
|
+
CURRENT_TIMESTAMP(6)
|
|
187
|
+
),
|
|
188
|
+
consecutive_failures = consecutive_failures + 1,
|
|
189
|
+
updated_at = CURRENT_TIMESTAMP(6)
|
|
190
|
+
WHERE name = ${params.name} AND lease_token = ${params.leaseToken}
|
|
191
|
+
`);
|
|
192
|
+
return affectedRowCount(result) === 1;
|
|
193
|
+
}
|
|
194
|
+
/** Return selected health rows, or every row when names is omitted. */
|
|
195
|
+
async health(names) {
|
|
196
|
+
if (names?.length === 0)
|
|
197
|
+
return [];
|
|
198
|
+
const whereClause = names === undefined
|
|
199
|
+
? sql ``
|
|
200
|
+
: sql `WHERE name IN (${sql.join(names.map((name) => sql `${name}`), sql `, `)})`;
|
|
201
|
+
const result = (await this.db.execute(sql `
|
|
202
|
+
SELECT
|
|
203
|
+
name,
|
|
204
|
+
interval_ms,
|
|
205
|
+
next_run_at,
|
|
206
|
+
last_status,
|
|
207
|
+
last_started_at,
|
|
208
|
+
last_succeeded_at,
|
|
209
|
+
last_failed_at,
|
|
210
|
+
last_duration_ms,
|
|
211
|
+
consecutive_failures,
|
|
212
|
+
last_error,
|
|
213
|
+
(lease_expires_at IS NOT NULL AND lease_expires_at <= CURRENT_TIMESTAMP(6)) AS lease_expired,
|
|
214
|
+
CURRENT_TIMESTAMP(6) AS database_now
|
|
215
|
+
FROM byline_recurring_tasks
|
|
216
|
+
${whereClause}
|
|
217
|
+
ORDER BY name
|
|
218
|
+
`));
|
|
219
|
+
return result[0].map((row) => ({
|
|
220
|
+
name: row.name,
|
|
221
|
+
intervalMs: Number(row.interval_ms),
|
|
222
|
+
nextRunAt: requiredDate(row.next_run_at, 'next_run_at'),
|
|
223
|
+
lastStatus: row.last_status,
|
|
224
|
+
lastStartedAt: toDate(row.last_started_at, 'last_started_at'),
|
|
225
|
+
lastSucceededAt: toDate(row.last_succeeded_at, 'last_succeeded_at'),
|
|
226
|
+
lastFailedAt: toDate(row.last_failed_at, 'last_failed_at'),
|
|
227
|
+
lastDurationMs: row.last_duration_ms === null ? null : Number(row.last_duration_ms),
|
|
228
|
+
consecutiveFailures: Number(row.consecutive_failures),
|
|
229
|
+
lastError: row.last_error,
|
|
230
|
+
leaseExpired: Boolean(row.lease_expired),
|
|
231
|
+
databaseNow: requiredDate(row.database_now, 'database_now'),
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export function createSchedulerStore(db) {
|
|
236
|
+
return new SchedulerStore(db);
|
|
237
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { ClaimedDocumentPublishSchedule, ConfirmDocumentPublishScheduleResult, DocumentPublishSchedule, DocumentPublishSchedulePage, DocumentPublishScheduleState, IDocumentPublishScheduleCommands, IDocumentPublishScheduleQueries, ScheduleDocumentPublishResult, SuspendDocumentPublishScheduleResult } from '@byline/core';
|
|
9
|
+
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
|
10
|
+
import type * as schema from '../../database/schema/index.js';
|
|
11
|
+
import type { DBManager } from '../../lib/db-manager.js';
|
|
12
|
+
type DatabaseConnection = MySql2Database<typeof schema>;
|
|
13
|
+
/** MySQL scheduled-publication writes on the ambient document transaction. */
|
|
14
|
+
export declare class DocumentPublishScheduleCommands implements IDocumentPublishScheduleCommands {
|
|
15
|
+
private dbManager;
|
|
16
|
+
constructor(dbManager: DBManager);
|
|
17
|
+
private get db();
|
|
18
|
+
private lockCurrentVersion;
|
|
19
|
+
private lockSchedule;
|
|
20
|
+
private readSchedule;
|
|
21
|
+
schedule(params: {
|
|
22
|
+
documentId: string;
|
|
23
|
+
collectionId: string;
|
|
24
|
+
expectedVersionId: string;
|
|
25
|
+
publishAt: Date;
|
|
26
|
+
actorId: string | null;
|
|
27
|
+
}): Promise<ScheduleDocumentPublishResult>;
|
|
28
|
+
confirm(params: {
|
|
29
|
+
documentId: string;
|
|
30
|
+
collectionId: string;
|
|
31
|
+
expectedVersionId: string;
|
|
32
|
+
actorId: string | null;
|
|
33
|
+
}): Promise<ConfirmDocumentPublishScheduleResult>;
|
|
34
|
+
cancel(params: {
|
|
35
|
+
documentId: string;
|
|
36
|
+
collectionId: string;
|
|
37
|
+
}): Promise<DocumentPublishSchedule | null>;
|
|
38
|
+
suspendForContentEdit(params: {
|
|
39
|
+
documentId: string;
|
|
40
|
+
collectionId: string;
|
|
41
|
+
}): Promise<SuspendDocumentPublishScheduleResult>;
|
|
42
|
+
claimDue(params: {
|
|
43
|
+
batchSize: number;
|
|
44
|
+
leaseMs: number;
|
|
45
|
+
}): Promise<ClaimedDocumentPublishSchedule[]>;
|
|
46
|
+
lockClaim(params: {
|
|
47
|
+
documentId: string;
|
|
48
|
+
executionToken: string;
|
|
49
|
+
}): Promise<DocumentPublishSchedule | null>;
|
|
50
|
+
deleteClaim(params: {
|
|
51
|
+
documentId: string;
|
|
52
|
+
executionToken: string;
|
|
53
|
+
}): Promise<boolean>;
|
|
54
|
+
suspendClaimForContentEdit(params: {
|
|
55
|
+
documentId: string;
|
|
56
|
+
executionToken: string;
|
|
57
|
+
}): Promise<boolean>;
|
|
58
|
+
/**
|
|
59
|
+
* `attempt_count` is read but not assigned, so MySQL's left-to-right SET
|
|
60
|
+
* evaluation cannot shift the backoff sequence. If it is ever assigned in
|
|
61
|
+
* this statement, keep `next_attempt_at` before that assignment.
|
|
62
|
+
*/
|
|
63
|
+
releaseClaim(params: {
|
|
64
|
+
documentId: string;
|
|
65
|
+
executionToken: string;
|
|
66
|
+
error: string;
|
|
67
|
+
}): Promise<boolean>;
|
|
68
|
+
}
|
|
69
|
+
/** MySQL scheduled-publication reads. */
|
|
70
|
+
export declare class DocumentPublishScheduleQueries implements IDocumentPublishScheduleQueries {
|
|
71
|
+
private db;
|
|
72
|
+
constructor(db: DatabaseConnection);
|
|
73
|
+
get(params: {
|
|
74
|
+
documentId: string;
|
|
75
|
+
collectionId: string;
|
|
76
|
+
}): Promise<DocumentPublishSchedule | null>;
|
|
77
|
+
list(params: {
|
|
78
|
+
collectionIds: readonly string[];
|
|
79
|
+
states?: readonly DocumentPublishScheduleState[];
|
|
80
|
+
lastAuthorizedBy?: string;
|
|
81
|
+
page: number;
|
|
82
|
+
pageSize: number;
|
|
83
|
+
}): Promise<DocumentPublishSchedulePage>;
|
|
84
|
+
}
|
|
85
|
+
export {};
|