@h-ai/reach 0.1.0-alpha.74 → 0.1.0-alpha.76
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.en.md +2 -0
- package/README.ja.md +2 -0
- package/README.md +2 -0
- package/dist/index.js +70 -48
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/README.en.md
CHANGED
|
@@ -223,6 +223,8 @@ An empty successful 2xx response, including 204/205, means receiver acceptance;
|
|
|
223
223
|
|
|
224
224
|
API `redirect` defaults to `error`; ordinary messages may explicitly use `follow`. Sensitive messages always reject redirects without forwarding payloads or private headers. `requireHttps:true` enforces initial HTTPS and cannot be combined with follow. When omitted, production sensitive messages require HTTPS; explicit false is only for an operator-approved trusted internal connection. Unknown source-side effects still require reconciliation and sensitive messages are never retried automatically. Application allowlists and network egress controls remain necessary.
|
|
225
225
|
|
|
226
|
+
PostgreSQL/MySQL queue claims, renewals and final acknowledgements use the database execution clock inside SQL; batch cutoffs also come from the database. Node clock skew and row-lock waits cannot revive an expired owner. Local SQLite remains a development/test backend with a controllable test clock.
|
|
227
|
+
|
|
226
228
|
## Development and testing
|
|
227
229
|
|
|
228
230
|
```bash
|
package/README.ja.md
CHANGED
|
@@ -223,6 +223,8 @@ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
|
223
223
|
|
|
224
224
|
API の `redirect` は既定で `error`、通常通知のみ明示的に `follow` を選択できます。sensitive は常に転送を拒否し、本文や独自ヘッダーを転送しません。`requireHttps:true` は開始URLの HTTPS を必須とし follow と併用できません。省略時は本番の機密通知に HTTPS を要求し、明示 false は運用者が確認した信頼できる内部接続専用です。元の送信先での副作用が不明なら確認が必要で、機密通知は自動再送しません。アプリの許可リストとネットワーク出口制御は引き続き必要です。
|
|
225
225
|
|
|
226
|
+
PostgreSQL/MySQLの取得・更新・最終確認はSQL実行時のDB時計を使い、走査期限もDBから取得します。ノード時差や行ロック待機で期限切れ所有者を復活させません。SQLiteの制御可能な時計は開発・テスト専用です。
|
|
227
|
+
|
|
226
228
|
## 開発とテスト
|
|
227
229
|
|
|
228
230
|
```bash
|
package/README.md
CHANGED
|
@@ -223,6 +223,8 @@ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
|
223
223
|
|
|
224
224
|
API Provider 的 `redirect` 默认为 `error`;只有普通消息可显式设为 `follow`。`sensitive:true` 始终拒绝跳转,不转发正文和私有头。`requireHttps:true` 强制初始 HTTPS 且与 follow 互斥;省略时生产敏感消息要求 HTTPS,显式 false 仅适用于操作者确认的受信内网。超时或源端已产生的未知副作用仍须核对,敏感消息不自动重投。域名授权、内网地址限制和出口策略由应用及部署环境负责。
|
|
225
225
|
|
|
226
|
+
PostgreSQL/MySQL 队列的领取、续租和最终确认使用 SQL 执行时的数据库时钟;扫描截止也从数据库读取。节点时差及行锁等待跨过期限后不能续活旧 owner。本地 SQLite 只用于开发/测试,可控制测试时钟,不作为生产共享状态。
|
|
227
|
+
|
|
226
228
|
## 开发测试
|
|
227
229
|
|
|
228
230
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -951,7 +951,12 @@ async function flushPendingMessages(providers2, repo) {
|
|
|
951
951
|
}
|
|
952
952
|
}
|
|
953
953
|
try {
|
|
954
|
-
const
|
|
954
|
+
const clock = await repo.currentTime();
|
|
955
|
+
if (!clock.success) {
|
|
956
|
+
logger6.warn("Failed to read queue clock", { errorCode: clock.error.code });
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const eligibleBefore = clock.data;
|
|
955
960
|
for (let processed = 0; processed < FLUSH_BATCH_SIZE; processed += 1) {
|
|
956
961
|
const claimResult = await repo.claimPendingBatch(flushOwner, {
|
|
957
962
|
limit: 1,
|
|
@@ -1266,9 +1271,11 @@ function migrateReach(options = {}, db = reldb) {
|
|
|
1266
1271
|
var TABLE_NAME = "hai_reach_send_log";
|
|
1267
1272
|
var DEFAULT_CLAIM_BATCH_SIZE = 100;
|
|
1268
1273
|
var DEFAULT_CLAIM_LOCK_MS = 10 * 60 * 1e3;
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1274
|
+
function claimableWhere(clock) {
|
|
1275
|
+
return `(attempt_count < max_attempts AND ((status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at < ? AND (locked_until IS NULL OR locked_until <= ${clock})) OR (status = ? AND updated_at < ? AND retry_safe = TRUE AND locked_until <= ${clock})))`;
|
|
1276
|
+
}
|
|
1277
|
+
function claimableParams(eligibleBefore) {
|
|
1278
|
+
return ["pending", eligibleBefore, "processing", eligibleBefore, "sending", eligibleBefore];
|
|
1272
1279
|
}
|
|
1273
1280
|
function buildSendLogError(detail, cause) {
|
|
1274
1281
|
return err(
|
|
@@ -1479,6 +1486,18 @@ async function createSendLogRepository(db) {
|
|
|
1479
1486
|
return ok(repo);
|
|
1480
1487
|
}
|
|
1481
1488
|
var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
1489
|
+
/** 共享后端取真正执行时钟;本地 SQLite 保留可控的单进程测试时钟。 */
|
|
1490
|
+
clockSql(localNow) {
|
|
1491
|
+
if (this.db.config?.type === "postgresql")
|
|
1492
|
+
return "CAST(FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) AS BIGINT)";
|
|
1493
|
+
if (this.db.config?.type === "mysql")
|
|
1494
|
+
return "CAST(UNIX_TIMESTAMP(SYSDATE(3)) * 1000 AS UNSIGNED)";
|
|
1495
|
+
return String(localNow ?? Date.now());
|
|
1496
|
+
}
|
|
1497
|
+
async currentTime() {
|
|
1498
|
+
const time = await this.sql().get(`SELECT ${this.clockSql()} AS now`);
|
|
1499
|
+
return time.success && time.data && Number.isSafeInteger(Number(time.data.now)) ? ok(Number(time.data.now)) : buildSendLogError(reachM("reach_claimLost"));
|
|
1500
|
+
}
|
|
1482
1501
|
async settleSensitive(queueId, status, messageId) {
|
|
1483
1502
|
const settled = await this.sql().execute(
|
|
1484
1503
|
`UPDATE ${TABLE_NAME} SET status = ?, message_id = ?, locked_until = NULL, updated_at = ? WHERE queue_id = ? AND status = 'sending' AND body IS NULL AND vars_json IS NULL AND retry_safe = FALSE`,
|
|
@@ -1538,15 +1557,15 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1538
1557
|
* - 并发事务会直接跳过这些行,而不是重复命中同一批记录
|
|
1539
1558
|
* - 在 commit 前不会暴露给其它节点重复 claim
|
|
1540
1559
|
*/
|
|
1541
|
-
async claimPendingIdsWithRowLocks(tx, owner,
|
|
1560
|
+
async claimPendingIdsWithRowLocks(tx, owner, clock, lockMs, limit, eligibleBefore) {
|
|
1542
1561
|
const lockedRowsResult = await tx.query(
|
|
1543
1562
|
`SELECT id
|
|
1544
1563
|
FROM ${TABLE_NAME}
|
|
1545
|
-
WHERE ${
|
|
1564
|
+
WHERE ${claimableWhere(clock)}
|
|
1546
1565
|
ORDER BY created_at ASC
|
|
1547
1566
|
LIMIT ?
|
|
1548
1567
|
FOR UPDATE SKIP LOCKED`,
|
|
1549
|
-
[...claimableParams(
|
|
1568
|
+
[...claimableParams(eligibleBefore), limit]
|
|
1550
1569
|
);
|
|
1551
1570
|
if (!lockedRowsResult.success) {
|
|
1552
1571
|
throw lockedRowsResult.error;
|
|
@@ -1564,9 +1583,9 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1564
1583
|
const placeholders = claimedIds.map(() => "?").join(", ");
|
|
1565
1584
|
const updateResult = await tx.execute(
|
|
1566
1585
|
`UPDATE ${TABLE_NAME}
|
|
1567
|
-
SET status = ?, processing_owner = ?, locked_until = ?, updated_at =
|
|
1586
|
+
SET status = ?, processing_owner = ?, locked_until = ${clock} + ?, updated_at = ${clock}, attempt_count = attempt_count + 1
|
|
1568
1587
|
WHERE id IN (${placeholders})`,
|
|
1569
|
-
["processing", owner,
|
|
1588
|
+
["processing", owner, lockMs, ...claimedIds]
|
|
1570
1589
|
);
|
|
1571
1590
|
if (!updateResult.success) {
|
|
1572
1591
|
throw updateResult.error;
|
|
@@ -1580,10 +1599,10 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1580
1599
|
* 即使多个节点读到了同一候选,只有第一个满足条件的 UPDATE 会成功,
|
|
1581
1600
|
* 从而避免同一行被两个节点同时 claim。
|
|
1582
1601
|
*/
|
|
1583
|
-
async claimPendingIdsWithConditionalUpdates(tx, owner,
|
|
1602
|
+
async claimPendingIdsWithConditionalUpdates(tx, owner, clock, lockMs, limit, eligibleBefore) {
|
|
1584
1603
|
const candidatesResult = await this.findAll({
|
|
1585
|
-
where:
|
|
1586
|
-
params: claimableParams(
|
|
1604
|
+
where: claimableWhere(clock),
|
|
1605
|
+
params: claimableParams(eligibleBefore),
|
|
1587
1606
|
orderBy: "created_at ASC",
|
|
1588
1607
|
limit
|
|
1589
1608
|
}, tx);
|
|
@@ -1594,9 +1613,9 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1594
1613
|
for (const row of candidatesResult.data) {
|
|
1595
1614
|
const updateResult = await tx.execute(
|
|
1596
1615
|
`UPDATE ${TABLE_NAME}
|
|
1597
|
-
SET status = ?, processing_owner = ?, locked_until = ?, updated_at =
|
|
1598
|
-
WHERE id = ? AND ${
|
|
1599
|
-
["processing", owner,
|
|
1616
|
+
SET status = ?, processing_owner = ?, locked_until = ${clock} + ?, updated_at = ${clock}, attempt_count = attempt_count + 1
|
|
1617
|
+
WHERE id = ? AND ${claimableWhere(clock)}`,
|
|
1618
|
+
["processing", owner, lockMs, row.id, ...claimableParams(eligibleBefore)]
|
|
1600
1619
|
);
|
|
1601
1620
|
if (!updateResult.success) {
|
|
1602
1621
|
throw updateResult.error;
|
|
@@ -1622,10 +1641,13 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1622
1641
|
}
|
|
1623
1642
|
/** claim 一批待发送记录,避免多节点重复发送 */
|
|
1624
1643
|
async claimPendingBatch(owner, options) {
|
|
1625
|
-
const
|
|
1644
|
+
const time = await this.currentTime();
|
|
1645
|
+
if (!time.success)
|
|
1646
|
+
return time;
|
|
1647
|
+
const now = this.db.config?.type === "sqlite" ? options?.now ?? time.data : time.data;
|
|
1648
|
+
const clock = this.clockSql(now);
|
|
1626
1649
|
const limit = options?.limit ?? DEFAULT_CLAIM_BATCH_SIZE;
|
|
1627
1650
|
const lockMs = options?.lockMs ?? DEFAULT_CLAIM_LOCK_MS;
|
|
1628
|
-
const lockedUntil = now + lockMs;
|
|
1629
1651
|
const eligibleBefore = Math.min(now, options?.eligibleBefore ?? now);
|
|
1630
1652
|
const dbType = this.db.config?.type;
|
|
1631
1653
|
const txResult = await this.db.tx.begin();
|
|
@@ -1635,23 +1657,23 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1635
1657
|
const tx = txResult.data;
|
|
1636
1658
|
try {
|
|
1637
1659
|
const unknown = await tx.execute(
|
|
1638
|
-
`UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at =
|
|
1639
|
-
WHERE status = ? AND retry_safe = FALSE AND (locked_until IS NULL OR locked_until <=
|
|
1640
|
-
["unknown",
|
|
1660
|
+
`UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${clock}
|
|
1661
|
+
WHERE status = ? AND retry_safe = FALSE AND (locked_until IS NULL OR locked_until <= ${clock})`,
|
|
1662
|
+
["unknown", "sending"]
|
|
1641
1663
|
);
|
|
1642
1664
|
if (!unknown.success)
|
|
1643
1665
|
throw unknown.error;
|
|
1644
1666
|
const exhausted = await tx.execute(
|
|
1645
1667
|
`UPDATE ${TABLE_NAME}
|
|
1646
|
-
SET status = ?, last_failure = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at =
|
|
1668
|
+
SET status = ?, last_failure = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${clock}
|
|
1647
1669
|
WHERE attempt_count >= max_attempts
|
|
1648
|
-
AND (status = ? OR (status = ? AND (locked_until IS NULL OR locked_until <=
|
|
1649
|
-
OR (status = ? AND retry_safe = TRUE AND locked_until <=
|
|
1650
|
-
["dead", "attempts_exhausted",
|
|
1670
|
+
AND (status = ? OR (status = ? AND (locked_until IS NULL OR locked_until <= ${clock}))
|
|
1671
|
+
OR (status = ? AND retry_safe = TRUE AND locked_until <= ${clock}))`,
|
|
1672
|
+
["dead", "attempts_exhausted", "pending", "processing", "sending"]
|
|
1651
1673
|
);
|
|
1652
1674
|
if (!exhausted.success)
|
|
1653
1675
|
throw exhausted.error;
|
|
1654
|
-
const claimedIds = dbType === "postgresql" || dbType === "mysql" ? await this.claimPendingIdsWithRowLocks(tx, owner,
|
|
1676
|
+
const claimedIds = dbType === "postgresql" || dbType === "mysql" ? await this.claimPendingIdsWithRowLocks(tx, owner, clock, lockMs, limit, eligibleBefore) : await this.claimPendingIdsWithConditionalUpdates(tx, owner, clock, lockMs, limit, eligibleBefore);
|
|
1655
1677
|
if (claimedIds.length === 0) {
|
|
1656
1678
|
const commitResult2 = await tx.commit();
|
|
1657
1679
|
if (!commitResult2.success) {
|
|
@@ -1679,15 +1701,15 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1679
1701
|
}
|
|
1680
1702
|
/** 失败提交与 owner、次数和有效租约同时比较,旧 worker 不能更改新领取的退避策略。 */
|
|
1681
1703
|
async failClaim(row, owner, reason, permanent = false, tx) {
|
|
1682
|
-
const
|
|
1704
|
+
const clock = this.clockSql();
|
|
1683
1705
|
const dead = permanent || row.attemptCount >= row.maxAttempts;
|
|
1684
1706
|
const delay = Math.min(row.maxRetryDelayMs, row.retryDelayMs * 2 ** Math.min(row.attemptCount - 1, 30));
|
|
1685
1707
|
const result = await this.sql(tx).execute(
|
|
1686
1708
|
`UPDATE ${TABLE_NAME}
|
|
1687
|
-
SET status = ?, last_failure = ?, next_attempt_at =
|
|
1688
|
-
WHERE id = ? AND processing_owner = ? AND locked_until >
|
|
1709
|
+
SET status = ?, last_failure = ?, next_attempt_at = ${dead ? "0" : `${clock} + ?`}, processing_owner = NULL, locked_until = NULL, updated_at = ${clock}
|
|
1710
|
+
WHERE id = ? AND processing_owner = ? AND locked_until > ${clock} AND attempt_count = ?
|
|
1689
1711
|
AND (status = ? OR (status = ? AND retry_safe = TRUE))`,
|
|
1690
|
-
[dead ? "dead" : "pending", reason, dead ?
|
|
1712
|
+
[dead ? "dead" : "pending", reason, ...dead ? [] : [delay], row.id, owner, row.attemptCount, "processing", "sending"]
|
|
1691
1713
|
);
|
|
1692
1714
|
if (!result.success) {
|
|
1693
1715
|
return buildSendLogError(result.error.message, result.error);
|
|
@@ -1699,60 +1721,60 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1699
1721
|
}
|
|
1700
1722
|
/** 仅当前 dead 版本可以恢复;保留 queueId,迟到的幂等 API 请求仍使用同一键。 */
|
|
1701
1723
|
async requeueDead(queueId, expectedUpdatedAt) {
|
|
1702
|
-
const
|
|
1724
|
+
const clock = this.clockSql();
|
|
1703
1725
|
const result = await this.sql().execute(
|
|
1704
|
-
`UPDATE ${TABLE_NAME} SET status = ?, attempt_count = 0, next_attempt_at =
|
|
1726
|
+
`UPDATE ${TABLE_NAME} SET status = ?, attempt_count = 0, next_attempt_at = ${clock}, last_failure = NULL, updated_at = CASE WHEN updated_at >= ${clock} THEN updated_at + 1 ELSE ${clock} END
|
|
1705
1727
|
WHERE queue_id = ? AND status = ? AND updated_at = ?`,
|
|
1706
|
-
["pending",
|
|
1728
|
+
["pending", queueId, "dead", expectedUpdatedAt]
|
|
1707
1729
|
);
|
|
1708
1730
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_deadRequeueFailed"));
|
|
1709
1731
|
}
|
|
1710
1732
|
/** 发送前持久化门闩;仅有效租约可从 processing 进入 sending。 */
|
|
1711
1733
|
async beginDelivery(id, owner, retrySafe, lockMs = DEFAULT_CLAIM_LOCK_MS) {
|
|
1712
|
-
const
|
|
1734
|
+
const clock = this.clockSql();
|
|
1713
1735
|
const flag = this.db.config?.type === "postgresql" ? retrySafe : Number(retrySafe);
|
|
1714
1736
|
const result = await this.sql().execute(
|
|
1715
|
-
`UPDATE ${TABLE_NAME} SET status = ?, retry_safe = ?, locked_until = ?, updated_at =
|
|
1716
|
-
WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until >
|
|
1717
|
-
["sending", flag,
|
|
1737
|
+
`UPDATE ${TABLE_NAME} SET status = ?, retry_safe = ?, locked_until = ${clock} + ?, updated_at = ${clock}
|
|
1738
|
+
WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until > ${clock}`,
|
|
1739
|
+
["sending", flag, lockMs, id, "processing", owner]
|
|
1718
1740
|
);
|
|
1719
1741
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
|
|
1720
1742
|
}
|
|
1721
1743
|
/** 续租不接受已经过期的 owner,防止暂停的旧进程恢复后重新取得发送权。 */
|
|
1722
1744
|
async renewClaim(id, owner, lockMs = DEFAULT_CLAIM_LOCK_MS) {
|
|
1723
|
-
const
|
|
1745
|
+
const clock = this.clockSql();
|
|
1724
1746
|
const result = await this.sql().execute(
|
|
1725
|
-
`UPDATE ${TABLE_NAME} SET locked_until = ?, updated_at =
|
|
1726
|
-
WHERE id = ? AND processing_owner = ? AND status IN (?, ?) AND locked_until >
|
|
1727
|
-
[
|
|
1747
|
+
`UPDATE ${TABLE_NAME} SET locked_until = ${clock} + ?, updated_at = ${clock}
|
|
1748
|
+
WHERE id = ? AND processing_owner = ? AND status IN (?, ?) AND locked_until > ${clock}`,
|
|
1749
|
+
[lockMs, id, owner, "processing", "sending"]
|
|
1728
1750
|
);
|
|
1729
1751
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
|
|
1730
1752
|
}
|
|
1731
1753
|
/** 未知结果只能停止自动处理,不推断外部副作用已撤销。 */
|
|
1732
1754
|
async markUnknown(id, owner) {
|
|
1733
1755
|
const result = await this.sql().execute(
|
|
1734
|
-
`UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at =
|
|
1756
|
+
`UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${this.clockSql()}
|
|
1735
1757
|
WHERE id = ? AND processing_owner = ? AND status IN (?, ?)`,
|
|
1736
|
-
["unknown",
|
|
1758
|
+
["unknown", id, owner, "processing", "sending"]
|
|
1737
1759
|
);
|
|
1738
1760
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
|
|
1739
1761
|
}
|
|
1740
1762
|
/** 人工裁定的条件提交,不提供重新排队或重新发送操作。 */
|
|
1741
1763
|
async resolveUnknown(queueId, resolution) {
|
|
1742
1764
|
const result = await this.sql().execute(
|
|
1743
|
-
`UPDATE ${TABLE_NAME} SET status = ?, message_id = ?, next_attempt_at = 0, updated_at =
|
|
1744
|
-
[resolution.status, resolution.messageId ?? null,
|
|
1765
|
+
`UPDATE ${TABLE_NAME} SET status = ?, message_id = ?, next_attempt_at = 0, updated_at = ${this.clockSql()} WHERE queue_id = ? AND status = ?`,
|
|
1766
|
+
[resolution.status, resolution.messageId ?? null, queueId, "unknown"]
|
|
1745
1767
|
);
|
|
1746
1768
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_unknownResolutionFailed"));
|
|
1747
1769
|
}
|
|
1748
1770
|
/** 只有仍持有 sending 有效租约的 owner 可以确认投递。 */
|
|
1749
1771
|
async markSent(id, owner, messageId, tx) {
|
|
1750
|
-
const
|
|
1772
|
+
const clock = this.clockSql();
|
|
1751
1773
|
const result = await this.sql(tx).execute(
|
|
1752
1774
|
`UPDATE ${TABLE_NAME}
|
|
1753
|
-
SET status = ?, message_id = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at =
|
|
1754
|
-
WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until >
|
|
1755
|
-
["sent", messageId ?? null,
|
|
1775
|
+
SET status = ?, message_id = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${clock}
|
|
1776
|
+
WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until > ${clock}`,
|
|
1777
|
+
["sent", messageId ?? null, id, "sending", owner]
|
|
1756
1778
|
);
|
|
1757
1779
|
if (!result.success) {
|
|
1758
1780
|
return buildSendLogError(result.error.message, result.error);
|