@h-ai/reach 0.1.0-alpha.73 → 0.1.0-alpha.75
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 +8 -0
- package/README.ja.md +8 -0
- package/README.md +8 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +147 -63
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/README.en.md
CHANGED
|
@@ -217,6 +217,14 @@ Use `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })` for passwo
|
|
|
217
217
|
|
|
218
218
|
`migrateReach({ dryRun: true })` previews supported migrations, applied during repository initialization. Statuses/identifiers remain; legacy in-flight work becomes unknown. Legacy payloads are retired once and pending old payloads discarded. Back up and stop old writers before upgrading; never replay old credentials.
|
|
219
219
|
|
|
220
|
+
## API acknowledgements and destination boundaries
|
|
221
|
+
|
|
222
|
+
An empty successful 2xx response, including 204/205, means receiver acceptance; messageId is optional and acceptance does not prove final delivery. Nonempty responses must be JSON objects with an optional messageId string of at most 255 characters. Responses are bounded to 64 KiB. Invalid responses, timeouts and network failures never become false success or expose receiver content.
|
|
223
|
+
|
|
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
|
+
|
|
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
|
+
|
|
220
228
|
## Development and testing
|
|
221
229
|
|
|
222
230
|
```bash
|
package/README.ja.md
CHANGED
|
@@ -217,6 +217,14 @@ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
|
217
217
|
|
|
218
218
|
`migrateReach({ dryRun: true })` で事前確認し、リポジトリー初期化で対応する移行を実行します。状態と識別子を保持し、旧処理中は unknown、旧ペイロードは一度廃棄し、旧待機ペイロードは discarded にします。更新前は旧書込を停止しバックアップし、古い認証情報を再送しないでください。
|
|
219
219
|
|
|
220
|
+
## API 応答と送信先の境界
|
|
221
|
+
|
|
222
|
+
204/205 を含む空の成功 2xx は受付成功を表し、messageId は任意で、最終配信の証明ではありません。本文がある場合は JSON オブジェクトとし、messageId は255文字以下の任意文字列です。応答上限は64 KiBです。不正応答・タイムアウト・通信失敗を成功扱いにせず、応答本文をログへ出しません。
|
|
223
|
+
|
|
224
|
+
API の `redirect` は既定で `error`、通常通知のみ明示的に `follow` を選択できます。sensitive は常に転送を拒否し、本文や独自ヘッダーを転送しません。`requireHttps:true` は開始URLの HTTPS を必須とし follow と併用できません。省略時は本番の機密通知に HTTPS を要求し、明示 false は運用者が確認した信頼できる内部接続専用です。元の送信先での副作用が不明なら確認が必要で、機密通知は自動再送しません。アプリの許可リストとネットワーク出口制御は引き続き必要です。
|
|
225
|
+
|
|
226
|
+
PostgreSQL/MySQLの取得・更新・最終確認はSQL実行時のDB時計を使い、走査期限もDBから取得します。ノード時差や行ロック待機で期限切れ所有者を復活させません。SQLiteの制御可能な時計は開発・テスト専用です。
|
|
227
|
+
|
|
220
228
|
## 開発とテスト
|
|
221
229
|
|
|
222
230
|
```bash
|
package/README.md
CHANGED
|
@@ -217,6 +217,14 @@ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
|
217
217
|
|
|
218
218
|
`migrateReach({ dryRun: true })` 预检;仓库初始化自动执行受支持步骤。升级保留发送状态与标识,旧在途转 unknown;旧正文/主题/模板变量/extra 一次性退役,待发旧载荷 discarded。生产需先备份停旧写入,不能恢复旧凭据重新投递。
|
|
219
219
|
|
|
220
|
+
## API 回执与目标边界
|
|
221
|
+
|
|
222
|
+
空正文的成功 2xx(含 204/205)表示接收端受理,messageId 可缺省,不代表最终送达。非空正文须为 JSON 对象,messageId 若存在须为不超过 255 字符的字符串;响应最多 64 KiB,非法响应、超时和网络失败不会假装成功或输出第三方正文。
|
|
223
|
+
|
|
224
|
+
API Provider 的 `redirect` 默认为 `error`;只有普通消息可显式设为 `follow`。`sensitive:true` 始终拒绝跳转,不转发正文和私有头。`requireHttps:true` 强制初始 HTTPS 且与 follow 互斥;省略时生产敏感消息要求 HTTPS,显式 false 仅适用于操作者确认的受信内网。超时或源端已产生的未知副作用仍须核对,敏感消息不自动重投。域名授权、内网地址限制和出口策略由应用及部署环境负责。
|
|
225
|
+
|
|
226
|
+
PostgreSQL/MySQL 队列的领取、续租和最终确认使用 SQL 执行时的数据库时钟;扫描截止也从数据库读取。节点时差及行锁等待跨过期限后不能续活旧 owner。本地 SQLite 只用于开发/测试,可控制测试时钟,不作为生产共享状态。
|
|
227
|
+
|
|
220
228
|
## 开发测试
|
|
221
229
|
|
|
222
230
|
```bash
|
package/dist/index.d.ts
CHANGED
|
@@ -80,6 +80,11 @@ declare const ApiProviderConfigSchema: z.ZodObject<{
|
|
|
80
80
|
}>>;
|
|
81
81
|
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
82
82
|
timeout: z.ZodDefault<z.ZodNumber>;
|
|
83
|
+
redirect: z.ZodDefault<z.ZodEnum<{
|
|
84
|
+
error: "error";
|
|
85
|
+
follow: "follow";
|
|
86
|
+
}>>;
|
|
87
|
+
requireHttps: z.ZodOptional<z.ZodBoolean>;
|
|
83
88
|
idempotent: z.ZodDefault<z.ZodBoolean>;
|
|
84
89
|
}, z.core.$strip>;
|
|
85
90
|
/**
|
|
@@ -120,6 +125,11 @@ declare const ProviderConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
120
125
|
}>>;
|
|
121
126
|
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
122
127
|
timeout: z.ZodDefault<z.ZodNumber>;
|
|
128
|
+
redirect: z.ZodDefault<z.ZodEnum<{
|
|
129
|
+
error: "error";
|
|
130
|
+
follow: "follow";
|
|
131
|
+
}>>;
|
|
132
|
+
requireHttps: z.ZodOptional<z.ZodBoolean>;
|
|
123
133
|
idempotent: z.ZodDefault<z.ZodBoolean>;
|
|
124
134
|
}, z.core.$strip>], "type">;
|
|
125
135
|
/**
|
|
@@ -344,6 +354,11 @@ declare const ReachConfigSchema: z.ZodObject<{
|
|
|
344
354
|
}>>;
|
|
345
355
|
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
346
356
|
timeout: z.ZodDefault<z.ZodNumber>;
|
|
357
|
+
redirect: z.ZodDefault<z.ZodEnum<{
|
|
358
|
+
error: "error";
|
|
359
|
+
follow: "follow";
|
|
360
|
+
}>>;
|
|
361
|
+
requireHttps: z.ZodOptional<z.ZodBoolean>;
|
|
347
362
|
idempotent: z.ZodDefault<z.ZodBoolean>;
|
|
348
363
|
}, z.core.$strip>], "type">>;
|
|
349
364
|
templates: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
package/dist/index.js
CHANGED
|
@@ -53,7 +53,11 @@ var en_US_default = {
|
|
|
53
53
|
reach_productionProviderRequired: "Production requires a delivery provider; console is available only in development or explicit test mode",
|
|
54
54
|
reach_sensitivePolicyRequired: "Credential notifications require a database receipt, stable idempotency key and expiry",
|
|
55
55
|
reach_sensitiveDeliveryUnconfirmed: "Credential delivery is unconfirmed and will not be retried automatically. Check the receipt or request new credentials.",
|
|
56
|
-
reach_messageExpired: "The credentials expired before delivery"
|
|
56
|
+
reach_messageExpired: "The credentials expired before delivery",
|
|
57
|
+
reach_apiResponseTooLarge: "API acknowledgement exceeds the 64 KiB limit",
|
|
58
|
+
reach_apiResponseInvalid: "API acknowledgement must be empty or a JSON object with an optional messageId string",
|
|
59
|
+
reach_apiRequestFailed: "API request failed or timed out; delivery is unconfirmed",
|
|
60
|
+
reach_apiHttpsRequired: "This API policy requires HTTPS and disallows automatic redirects"
|
|
57
61
|
};
|
|
58
62
|
|
|
59
63
|
// messages/ja-JP.json
|
|
@@ -101,7 +105,11 @@ var ja_JP_default = {
|
|
|
101
105
|
reach_productionProviderRequired: "\u672C\u756A\u74B0\u5883\u306B\u306F\u914D\u4FE1\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u304C\u5FC5\u8981\u3067\u3059\u3002console \u306F\u958B\u767A\u74B0\u5883\u307E\u305F\u306F\u660E\u793A\u7684\u306A\u30C6\u30B9\u30C8\u30E2\u30FC\u30C9\u3067\u306E\u307F\u4F7F\u7528\u3067\u304D\u307E\u3059",
|
|
102
106
|
reach_sensitivePolicyRequired: "\u8A8D\u8A3C\u60C5\u5831\u306E\u901A\u77E5\u306B\u306F\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u306E\u53D7\u9818\u8A18\u9332\u3001\u56FA\u5B9A\u306E\u51AA\u7B49\u30AD\u30FC\u3068\u6709\u52B9\u671F\u9650\u304C\u5FC5\u8981\u3067\u3059",
|
|
103
107
|
reach_sensitiveDeliveryUnconfirmed: "\u8A8D\u8A3C\u60C5\u5831\u306E\u914D\u4FE1\u3092\u78BA\u8A8D\u3067\u304D\u307E\u305B\u3093\u3002\u81EA\u52D5\u518D\u9001\u305B\u305A\u3001\u53D7\u9818\u8A18\u9332\u306E\u78BA\u8A8D\u307E\u305F\u306F\u8A8D\u8A3C\u60C5\u5831\u306E\u518D\u767A\u884C\u304C\u5FC5\u8981\u3067\u3059\u3002",
|
|
104
|
-
reach_messageExpired: "\u8A8D\u8A3C\u60C5\u5831\u306E\u671F\u9650\u304C\u5207\u308C\u305F\u305F\u3081\u914D\u4FE1\u3057\u307E\u305B\u3093\u3067\u3057\u305F"
|
|
108
|
+
reach_messageExpired: "\u8A8D\u8A3C\u60C5\u5831\u306E\u671F\u9650\u304C\u5207\u308C\u305F\u305F\u3081\u914D\u4FE1\u3057\u307E\u305B\u3093\u3067\u3057\u305F",
|
|
109
|
+
reach_apiResponseTooLarge: "API \u5FDC\u7B54\u304C 64 KiB \u306E\u4E0A\u9650\u3092\u8D85\u3048\u3066\u3044\u307E\u3059",
|
|
110
|
+
reach_apiResponseInvalid: "API \u5FDC\u7B54\u306F\u7A7A\u304B\u3001\u7701\u7565\u53EF\u80FD\u306A messageId \u6587\u5B57\u5217\u3092\u6301\u3064 JSON \u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059",
|
|
111
|
+
reach_apiRequestFailed: "API \u30EA\u30AF\u30A8\u30B9\u30C8\u304C\u5931\u6557\u307E\u305F\u306F\u30BF\u30A4\u30E0\u30A2\u30A6\u30C8\u3057\u307E\u3057\u305F\u3002\u914D\u4FE1\u7D50\u679C\u306F\u672A\u78BA\u8A8D\u3067\u3059",
|
|
112
|
+
reach_apiHttpsRequired: "\u3053\u306E API \u30DD\u30EA\u30B7\u30FC\u3067\u306F HTTPS \u304C\u5FC5\u8981\u3067\u3001\u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u306F\u7981\u6B62\u3055\u308C\u3066\u3044\u307E\u3059"
|
|
105
113
|
};
|
|
106
114
|
|
|
107
115
|
// messages/zh-CN.json
|
|
@@ -149,7 +157,11 @@ var zh_CN_default = {
|
|
|
149
157
|
reach_productionProviderRequired: "\u751F\u4EA7\u73AF\u5883\u5FC5\u987B\u914D\u7F6E\u771F\u5B9E\u89E6\u8FBE\u670D\u52A1\uFF1Bconsole \u4EC5\u53EF\u7528\u4E8E\u5F00\u53D1\u6216\u663E\u5F0F\u6D4B\u8BD5\u6A21\u5F0F",
|
|
150
158
|
reach_sensitivePolicyRequired: "\u51ED\u636E\u901A\u77E5\u9700\u8981\u6570\u636E\u5E93\u56DE\u6267\u3001\u7A33\u5B9A\u5E42\u7B49\u952E\u548C\u6709\u6548\u671F",
|
|
151
159
|
reach_sensitiveDeliveryUnconfirmed: "\u51ED\u636E\u6295\u9012\u5C1A\u672A\u786E\u8BA4\uFF1B\u4E0D\u4F1A\u81EA\u52A8\u91CD\u590D\u53D1\u9001\uFF0C\u8BF7\u67E5\u8BE2\u56DE\u6267\u6216\u7533\u8BF7\u65B0\u51ED\u636E",
|
|
152
|
-
reach_messageExpired: "\u51ED\u636E\u5DF2\u8FC7\u671F\uFF0C\u672A\u6295\u9012"
|
|
160
|
+
reach_messageExpired: "\u51ED\u636E\u5DF2\u8FC7\u671F\uFF0C\u672A\u6295\u9012",
|
|
161
|
+
reach_apiResponseTooLarge: "API \u56DE\u6267\u8D85\u8FC7 64 KiB \u4E0A\u9650",
|
|
162
|
+
reach_apiResponseInvalid: "API \u56DE\u6267\u5FC5\u987B\u4E3A\u7A7A\u6216\u542B\u53EF\u9009 messageId \u5B57\u7B26\u4E32\u7684 JSON \u5BF9\u8C61",
|
|
163
|
+
reach_apiRequestFailed: "API \u8BF7\u6C42\u5931\u8D25\u6216\u8D85\u65F6\uFF0C\u6295\u9012\u7ED3\u679C\u672A\u786E\u8BA4",
|
|
164
|
+
reach_apiHttpsRequired: "\u6B64 API \u7B56\u7565\u8981\u6C42 HTTPS \u4E14\u7981\u6B62\u81EA\u52A8\u91CD\u5B9A\u5411"
|
|
153
165
|
};
|
|
154
166
|
|
|
155
167
|
// src/reach-i18n.ts
|
|
@@ -209,6 +221,10 @@ var ApiProviderConfigSchema = z.object({
|
|
|
209
221
|
headers: z.record(z.string(), z.string()).optional(),
|
|
210
222
|
/** 请求超时毫秒数(默认 10000) */
|
|
211
223
|
timeout: z.number().int().min(0).default(1e4),
|
|
224
|
+
/** HTTP 重定向策略;默认拒绝。敏感发送始终拒绝,即使普通发送显式允许。 */
|
|
225
|
+
redirect: z.enum(["error", "follow"]).default("error"),
|
|
226
|
+
/** 强制 HTTPS;省略时生产敏感发送要求 HTTPS,显式 false 仅用于受信内网传输。 */
|
|
227
|
+
requireHttps: z.boolean().optional(),
|
|
212
228
|
/** 接收端按 Idempotency-Key 原子持久去重,且覆盖所有重试与旧节点迟到请求时才启用;默认 false。 */
|
|
213
229
|
idempotent: z.boolean().default(false)
|
|
214
230
|
});
|
|
@@ -387,15 +403,51 @@ function getSafeEndpoint(url) {
|
|
|
387
403
|
return "[invalid-url]";
|
|
388
404
|
}
|
|
389
405
|
}
|
|
390
|
-
function toReachError2(
|
|
406
|
+
function toReachError2() {
|
|
391
407
|
return {
|
|
392
408
|
code: HaiReachError.SEND_FAILED.code,
|
|
393
409
|
message: reachM("reach_apiSendFailed", {
|
|
394
|
-
params: { error:
|
|
395
|
-
})
|
|
396
|
-
cause: error
|
|
410
|
+
params: { error: reachM("reach_apiRequestFailed") }
|
|
411
|
+
})
|
|
397
412
|
};
|
|
398
413
|
}
|
|
414
|
+
async function readAcknowledgement(response) {
|
|
415
|
+
const reader = response.body?.getReader();
|
|
416
|
+
let complete = false;
|
|
417
|
+
try {
|
|
418
|
+
let text = "";
|
|
419
|
+
let bytes = 0;
|
|
420
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
421
|
+
if (reader) {
|
|
422
|
+
while (true) {
|
|
423
|
+
const chunk = await reader.read();
|
|
424
|
+
if (chunk.done)
|
|
425
|
+
break;
|
|
426
|
+
bytes += chunk.value.byteLength;
|
|
427
|
+
if (bytes > 65536)
|
|
428
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseTooLarge"));
|
|
429
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
430
|
+
}
|
|
431
|
+
text += decoder.decode();
|
|
432
|
+
}
|
|
433
|
+
complete = true;
|
|
434
|
+
if (text.length === 0)
|
|
435
|
+
return ok({ success: true });
|
|
436
|
+
const result = JSON.parse(text);
|
|
437
|
+
if (!result || typeof result !== "object" || Array.isArray(result))
|
|
438
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
|
|
439
|
+
const messageId = "messageId" in result ? result.messageId : void 0;
|
|
440
|
+
if (messageId !== void 0 && (typeof messageId !== "string" || messageId.length > 255))
|
|
441
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
|
|
442
|
+
return ok({ success: true, ...messageId === void 0 ? {} : { messageId } });
|
|
443
|
+
} catch {
|
|
444
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
|
|
445
|
+
} finally {
|
|
446
|
+
if (!complete)
|
|
447
|
+
await reader?.cancel().catch(() => void 0);
|
|
448
|
+
reader?.releaseLock();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
399
451
|
function createApiProvider() {
|
|
400
452
|
let apiConfig = null;
|
|
401
453
|
return {
|
|
@@ -411,6 +463,10 @@ function createApiProvider() {
|
|
|
411
463
|
);
|
|
412
464
|
}
|
|
413
465
|
apiConfig = config;
|
|
466
|
+
if (config.requireHttps && new URL(config.url).protocol !== "https:" || config.requireHttps && config.redirect === "follow") {
|
|
467
|
+
apiConfig = null;
|
|
468
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_apiHttpsRequired"));
|
|
469
|
+
}
|
|
414
470
|
logger2.info("API provider connected", { endpoint: getSafeEndpoint(config.url), method: config.method });
|
|
415
471
|
return ok(void 0);
|
|
416
472
|
},
|
|
@@ -429,6 +485,9 @@ function createApiProvider() {
|
|
|
429
485
|
);
|
|
430
486
|
}
|
|
431
487
|
logger2.debug("Sending via API callback", { endpoint: getSafeEndpoint(apiConfig.url) });
|
|
488
|
+
const requireHttps = apiConfig.requireHttps ?? (message.sensitive === true && core.module.requiresSharedState(process.env));
|
|
489
|
+
if (requireHttps && new URL(apiConfig.url).protocol !== "https:")
|
|
490
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_apiHttpsRequired"));
|
|
432
491
|
try {
|
|
433
492
|
const payload = {
|
|
434
493
|
to: message.to,
|
|
@@ -450,25 +509,28 @@ function createApiProvider() {
|
|
|
450
509
|
method: apiConfig.method,
|
|
451
510
|
headers,
|
|
452
511
|
body: JSON.stringify(payload),
|
|
453
|
-
signal: controller.signal
|
|
512
|
+
signal: controller.signal,
|
|
513
|
+
redirect: message.sensitive || apiConfig.redirect !== "follow" ? "manual" : "follow"
|
|
454
514
|
});
|
|
455
515
|
if (!response.ok) {
|
|
516
|
+
await response.body?.cancel();
|
|
456
517
|
logger2.warn("API callback returned non-OK status", { status: response.status });
|
|
457
|
-
const rejected = response.status >= 400 && response.status < 500 && ![408, 425, 429].includes(response.status);
|
|
518
|
+
const rejected = response.status >= 300 && response.status < 400 || response.status >= 400 && response.status < 500 && ![408, 425, 429].includes(response.status);
|
|
458
519
|
return err(
|
|
459
520
|
rejected ? HaiReachError.DELIVERY_REJECTED : HaiReachError.SEND_FAILED,
|
|
460
521
|
reachM("reach_apiSendFailed", { params: { error: `HTTP ${response.status}` } })
|
|
461
522
|
);
|
|
462
523
|
}
|
|
463
|
-
const result = await response
|
|
464
|
-
|
|
465
|
-
|
|
524
|
+
const result = await readAcknowledgement(response);
|
|
525
|
+
if (result.success)
|
|
526
|
+
logger2.info("API callback accepted", { hasMessageId: result.data.messageId !== void 0 });
|
|
527
|
+
return result;
|
|
466
528
|
} finally {
|
|
467
529
|
clearTimeout(timeoutId);
|
|
468
530
|
}
|
|
469
|
-
} catch
|
|
470
|
-
logger2.error("API callback send failed", {
|
|
471
|
-
return err(toReachError2(
|
|
531
|
+
} catch {
|
|
532
|
+
logger2.error("API callback send failed", { errorCode: HaiReachError.SEND_FAILED.code });
|
|
533
|
+
return err(toReachError2());
|
|
472
534
|
}
|
|
473
535
|
}
|
|
474
536
|
};
|
|
@@ -889,7 +951,12 @@ async function flushPendingMessages(providers2, repo) {
|
|
|
889
951
|
}
|
|
890
952
|
}
|
|
891
953
|
try {
|
|
892
|
-
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;
|
|
893
960
|
for (let processed = 0; processed < FLUSH_BATCH_SIZE; processed += 1) {
|
|
894
961
|
const claimResult = await repo.claimPendingBatch(flushOwner, {
|
|
895
962
|
limit: 1,
|
|
@@ -1204,9 +1271,11 @@ function migrateReach(options = {}, db = reldb) {
|
|
|
1204
1271
|
var TABLE_NAME = "hai_reach_send_log";
|
|
1205
1272
|
var DEFAULT_CLAIM_BATCH_SIZE = 100;
|
|
1206
1273
|
var DEFAULT_CLAIM_LOCK_MS = 10 * 60 * 1e3;
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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];
|
|
1210
1279
|
}
|
|
1211
1280
|
function buildSendLogError(detail, cause) {
|
|
1212
1281
|
return err(
|
|
@@ -1417,6 +1486,18 @@ async function createSendLogRepository(db) {
|
|
|
1417
1486
|
return ok(repo);
|
|
1418
1487
|
}
|
|
1419
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
|
+
}
|
|
1420
1501
|
async settleSensitive(queueId, status, messageId) {
|
|
1421
1502
|
const settled = await this.sql().execute(
|
|
1422
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`,
|
|
@@ -1476,15 +1557,15 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1476
1557
|
* - 并发事务会直接跳过这些行,而不是重复命中同一批记录
|
|
1477
1558
|
* - 在 commit 前不会暴露给其它节点重复 claim
|
|
1478
1559
|
*/
|
|
1479
|
-
async claimPendingIdsWithRowLocks(tx, owner,
|
|
1560
|
+
async claimPendingIdsWithRowLocks(tx, owner, clock, lockMs, limit, eligibleBefore) {
|
|
1480
1561
|
const lockedRowsResult = await tx.query(
|
|
1481
1562
|
`SELECT id
|
|
1482
1563
|
FROM ${TABLE_NAME}
|
|
1483
|
-
WHERE ${
|
|
1564
|
+
WHERE ${claimableWhere(clock)}
|
|
1484
1565
|
ORDER BY created_at ASC
|
|
1485
1566
|
LIMIT ?
|
|
1486
1567
|
FOR UPDATE SKIP LOCKED`,
|
|
1487
|
-
[...claimableParams(
|
|
1568
|
+
[...claimableParams(eligibleBefore), limit]
|
|
1488
1569
|
);
|
|
1489
1570
|
if (!lockedRowsResult.success) {
|
|
1490
1571
|
throw lockedRowsResult.error;
|
|
@@ -1502,9 +1583,9 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1502
1583
|
const placeholders = claimedIds.map(() => "?").join(", ");
|
|
1503
1584
|
const updateResult = await tx.execute(
|
|
1504
1585
|
`UPDATE ${TABLE_NAME}
|
|
1505
|
-
SET status = ?, processing_owner = ?, locked_until = ?, updated_at =
|
|
1586
|
+
SET status = ?, processing_owner = ?, locked_until = ${clock} + ?, updated_at = ${clock}, attempt_count = attempt_count + 1
|
|
1506
1587
|
WHERE id IN (${placeholders})`,
|
|
1507
|
-
["processing", owner,
|
|
1588
|
+
["processing", owner, lockMs, ...claimedIds]
|
|
1508
1589
|
);
|
|
1509
1590
|
if (!updateResult.success) {
|
|
1510
1591
|
throw updateResult.error;
|
|
@@ -1518,10 +1599,10 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1518
1599
|
* 即使多个节点读到了同一候选,只有第一个满足条件的 UPDATE 会成功,
|
|
1519
1600
|
* 从而避免同一行被两个节点同时 claim。
|
|
1520
1601
|
*/
|
|
1521
|
-
async claimPendingIdsWithConditionalUpdates(tx, owner,
|
|
1602
|
+
async claimPendingIdsWithConditionalUpdates(tx, owner, clock, lockMs, limit, eligibleBefore) {
|
|
1522
1603
|
const candidatesResult = await this.findAll({
|
|
1523
|
-
where:
|
|
1524
|
-
params: claimableParams(
|
|
1604
|
+
where: claimableWhere(clock),
|
|
1605
|
+
params: claimableParams(eligibleBefore),
|
|
1525
1606
|
orderBy: "created_at ASC",
|
|
1526
1607
|
limit
|
|
1527
1608
|
}, tx);
|
|
@@ -1532,9 +1613,9 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1532
1613
|
for (const row of candidatesResult.data) {
|
|
1533
1614
|
const updateResult = await tx.execute(
|
|
1534
1615
|
`UPDATE ${TABLE_NAME}
|
|
1535
|
-
SET status = ?, processing_owner = ?, locked_until = ?, updated_at =
|
|
1536
|
-
WHERE id = ? AND ${
|
|
1537
|
-
["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)]
|
|
1538
1619
|
);
|
|
1539
1620
|
if (!updateResult.success) {
|
|
1540
1621
|
throw updateResult.error;
|
|
@@ -1560,10 +1641,13 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1560
1641
|
}
|
|
1561
1642
|
/** claim 一批待发送记录,避免多节点重复发送 */
|
|
1562
1643
|
async claimPendingBatch(owner, options) {
|
|
1563
|
-
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);
|
|
1564
1649
|
const limit = options?.limit ?? DEFAULT_CLAIM_BATCH_SIZE;
|
|
1565
1650
|
const lockMs = options?.lockMs ?? DEFAULT_CLAIM_LOCK_MS;
|
|
1566
|
-
const lockedUntil = now + lockMs;
|
|
1567
1651
|
const eligibleBefore = Math.min(now, options?.eligibleBefore ?? now);
|
|
1568
1652
|
const dbType = this.db.config?.type;
|
|
1569
1653
|
const txResult = await this.db.tx.begin();
|
|
@@ -1573,23 +1657,23 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1573
1657
|
const tx = txResult.data;
|
|
1574
1658
|
try {
|
|
1575
1659
|
const unknown = await tx.execute(
|
|
1576
|
-
`UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at =
|
|
1577
|
-
WHERE status = ? AND retry_safe = FALSE AND (locked_until IS NULL OR locked_until <=
|
|
1578
|
-
["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"]
|
|
1579
1663
|
);
|
|
1580
1664
|
if (!unknown.success)
|
|
1581
1665
|
throw unknown.error;
|
|
1582
1666
|
const exhausted = await tx.execute(
|
|
1583
1667
|
`UPDATE ${TABLE_NAME}
|
|
1584
|
-
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}
|
|
1585
1669
|
WHERE attempt_count >= max_attempts
|
|
1586
|
-
AND (status = ? OR (status = ? AND (locked_until IS NULL OR locked_until <=
|
|
1587
|
-
OR (status = ? AND retry_safe = TRUE AND locked_until <=
|
|
1588
|
-
["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"]
|
|
1589
1673
|
);
|
|
1590
1674
|
if (!exhausted.success)
|
|
1591
1675
|
throw exhausted.error;
|
|
1592
|
-
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);
|
|
1593
1677
|
if (claimedIds.length === 0) {
|
|
1594
1678
|
const commitResult2 = await tx.commit();
|
|
1595
1679
|
if (!commitResult2.success) {
|
|
@@ -1617,15 +1701,15 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1617
1701
|
}
|
|
1618
1702
|
/** 失败提交与 owner、次数和有效租约同时比较,旧 worker 不能更改新领取的退避策略。 */
|
|
1619
1703
|
async failClaim(row, owner, reason, permanent = false, tx) {
|
|
1620
|
-
const
|
|
1704
|
+
const clock = this.clockSql();
|
|
1621
1705
|
const dead = permanent || row.attemptCount >= row.maxAttempts;
|
|
1622
1706
|
const delay = Math.min(row.maxRetryDelayMs, row.retryDelayMs * 2 ** Math.min(row.attemptCount - 1, 30));
|
|
1623
1707
|
const result = await this.sql(tx).execute(
|
|
1624
1708
|
`UPDATE ${TABLE_NAME}
|
|
1625
|
-
SET status = ?, last_failure = ?, next_attempt_at =
|
|
1626
|
-
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 = ?
|
|
1627
1711
|
AND (status = ? OR (status = ? AND retry_safe = TRUE))`,
|
|
1628
|
-
[dead ? "dead" : "pending", reason, dead ?
|
|
1712
|
+
[dead ? "dead" : "pending", reason, ...dead ? [] : [delay], row.id, owner, row.attemptCount, "processing", "sending"]
|
|
1629
1713
|
);
|
|
1630
1714
|
if (!result.success) {
|
|
1631
1715
|
return buildSendLogError(result.error.message, result.error);
|
|
@@ -1637,60 +1721,60 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1637
1721
|
}
|
|
1638
1722
|
/** 仅当前 dead 版本可以恢复;保留 queueId,迟到的幂等 API 请求仍使用同一键。 */
|
|
1639
1723
|
async requeueDead(queueId, expectedUpdatedAt) {
|
|
1640
|
-
const
|
|
1724
|
+
const clock = this.clockSql();
|
|
1641
1725
|
const result = await this.sql().execute(
|
|
1642
|
-
`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
|
|
1643
1727
|
WHERE queue_id = ? AND status = ? AND updated_at = ?`,
|
|
1644
|
-
["pending",
|
|
1728
|
+
["pending", queueId, "dead", expectedUpdatedAt]
|
|
1645
1729
|
);
|
|
1646
1730
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_deadRequeueFailed"));
|
|
1647
1731
|
}
|
|
1648
1732
|
/** 发送前持久化门闩;仅有效租约可从 processing 进入 sending。 */
|
|
1649
1733
|
async beginDelivery(id, owner, retrySafe, lockMs = DEFAULT_CLAIM_LOCK_MS) {
|
|
1650
|
-
const
|
|
1734
|
+
const clock = this.clockSql();
|
|
1651
1735
|
const flag = this.db.config?.type === "postgresql" ? retrySafe : Number(retrySafe);
|
|
1652
1736
|
const result = await this.sql().execute(
|
|
1653
|
-
`UPDATE ${TABLE_NAME} SET status = ?, retry_safe = ?, locked_until = ?, updated_at =
|
|
1654
|
-
WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until >
|
|
1655
|
-
["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]
|
|
1656
1740
|
);
|
|
1657
1741
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
|
|
1658
1742
|
}
|
|
1659
1743
|
/** 续租不接受已经过期的 owner,防止暂停的旧进程恢复后重新取得发送权。 */
|
|
1660
1744
|
async renewClaim(id, owner, lockMs = DEFAULT_CLAIM_LOCK_MS) {
|
|
1661
|
-
const
|
|
1745
|
+
const clock = this.clockSql();
|
|
1662
1746
|
const result = await this.sql().execute(
|
|
1663
|
-
`UPDATE ${TABLE_NAME} SET locked_until = ?, updated_at =
|
|
1664
|
-
WHERE id = ? AND processing_owner = ? AND status IN (?, ?) AND locked_until >
|
|
1665
|
-
[
|
|
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"]
|
|
1666
1750
|
);
|
|
1667
1751
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
|
|
1668
1752
|
}
|
|
1669
1753
|
/** 未知结果只能停止自动处理,不推断外部副作用已撤销。 */
|
|
1670
1754
|
async markUnknown(id, owner) {
|
|
1671
1755
|
const result = await this.sql().execute(
|
|
1672
|
-
`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()}
|
|
1673
1757
|
WHERE id = ? AND processing_owner = ? AND status IN (?, ?)`,
|
|
1674
|
-
["unknown",
|
|
1758
|
+
["unknown", id, owner, "processing", "sending"]
|
|
1675
1759
|
);
|
|
1676
1760
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
|
|
1677
1761
|
}
|
|
1678
1762
|
/** 人工裁定的条件提交,不提供重新排队或重新发送操作。 */
|
|
1679
1763
|
async resolveUnknown(queueId, resolution) {
|
|
1680
1764
|
const result = await this.sql().execute(
|
|
1681
|
-
`UPDATE ${TABLE_NAME} SET status = ?, message_id = ?, next_attempt_at = 0, updated_at =
|
|
1682
|
-
[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"]
|
|
1683
1767
|
);
|
|
1684
1768
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_unknownResolutionFailed"));
|
|
1685
1769
|
}
|
|
1686
1770
|
/** 只有仍持有 sending 有效租约的 owner 可以确认投递。 */
|
|
1687
1771
|
async markSent(id, owner, messageId, tx) {
|
|
1688
|
-
const
|
|
1772
|
+
const clock = this.clockSql();
|
|
1689
1773
|
const result = await this.sql(tx).execute(
|
|
1690
1774
|
`UPDATE ${TABLE_NAME}
|
|
1691
|
-
SET status = ?, message_id = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at =
|
|
1692
|
-
WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until >
|
|
1693
|
-
["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]
|
|
1694
1778
|
);
|
|
1695
1779
|
if (!result.success) {
|
|
1696
1780
|
return buildSendLogError(result.error.message, result.error);
|