@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 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 eligibleBefore = Date.now();
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
- var CLAIMABLE_STATUS_WHERE = "(attempt_count < max_attempts AND ((status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at < ? AND (locked_until IS NULL OR locked_until <= ?)) OR (status = ? AND updated_at < ? AND retry_safe = TRUE AND locked_until <= ?)))";
1270
- function claimableParams(now, eligibleBefore = now) {
1271
- return ["pending", eligibleBefore, "processing", eligibleBefore, now, "sending", eligibleBefore, now];
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, now, lockedUntil, limit, eligibleBefore) {
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 ${CLAIMABLE_STATUS_WHERE}
1564
+ WHERE ${claimableWhere(clock)}
1546
1565
  ORDER BY created_at ASC
1547
1566
  LIMIT ?
1548
1567
  FOR UPDATE SKIP LOCKED`,
1549
- [...claimableParams(now, eligibleBefore), limit]
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 = ?, attempt_count = attempt_count + 1
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, lockedUntil, now, ...claimedIds]
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, now, lockedUntil, limit, eligibleBefore) {
1602
+ async claimPendingIdsWithConditionalUpdates(tx, owner, clock, lockMs, limit, eligibleBefore) {
1584
1603
  const candidatesResult = await this.findAll({
1585
- where: CLAIMABLE_STATUS_WHERE,
1586
- params: claimableParams(now, eligibleBefore),
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 = ?, attempt_count = attempt_count + 1
1598
- WHERE id = ? AND ${CLAIMABLE_STATUS_WHERE}`,
1599
- ["processing", owner, lockedUntil, now, row.id, ...claimableParams(now, eligibleBefore)]
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 now = options?.now ?? Date.now();
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", now, "sending", now]
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", now, "pending", "processing", now, "sending", now]
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, now, lockedUntil, limit, eligibleBefore) : await this.claimPendingIdsWithConditionalUpdates(tx, owner, now, lockedUntil, limit, eligibleBefore);
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 now = Date.now();
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 = ?, processing_owner = NULL, locked_until = NULL, updated_at = ?
1688
- WHERE id = ? AND processing_owner = ? AND locked_until > ? AND attempt_count = ?
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 ? 0 : now + delay, now, row.id, owner, now, row.attemptCount, "processing", "sending"]
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 now = Math.max(Date.now(), expectedUpdatedAt + 1);
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 = ?, last_failure = NULL, updated_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", now, now, queueId, "dead", expectedUpdatedAt]
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 now = Date.now();
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, now + lockMs, now, id, "processing", owner, now]
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 now = Date.now();
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
- [now + lockMs, now, id, owner, "processing", "sending", now]
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", Date.now(), id, owner, "processing", "sending"]
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 = ? WHERE queue_id = ? AND status = ?`,
1744
- [resolution.status, resolution.messageId ?? null, Date.now(), queueId, "unknown"]
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 now = Date.now();
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, now, id, "sending", owner, now]
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);