pending-task-kit 0.1.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/README.md ADDED
@@ -0,0 +1,266 @@
1
+ # pending-task-kit
2
+
3
+ Framework-agnostic engine for tracking and polling long-running background tasks
4
+ (AI generation jobs, async search, payment confirmation, batch operations, ...)
5
+ that can outlive the page that started them, plus an optional React binding.
6
+
7
+ Extracted from a production app's `PendingTaskNotifier` subsystem. The
8
+ notification channel (toasts, redirects, cache invalidation) is deliberately
9
+ **not** part of this package — you wire that up yourself via `onResult`.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pnpm add pending-task-kit zustand
15
+ # React binding also needs react as a peer dep (already true in a React app)
16
+ ```
17
+
18
+ ## Core concepts
19
+
20
+ - **Task** (`PendingTask`) — `{ id, type, taskId, startedAt, ttlMs?, metadata? }`, one row
21
+ tracked until it resolves or expires. There are no dedicated "owner"/"title"/"link" fields
22
+ — the engine reads nothing off a task except `id`/`type`/`taskId`/`startedAt`/
23
+ `lastCheckedAt`/`failureCount`/`ttlMs` (the last two it maintains itself, as dedicated
24
+ fields precisely so they can't collide with a key your own data happens to use). Anything
25
+ else your app wants attached to a task — a display title, a link, a user/tenant id to scope
26
+ by — goes in the fully free-form `metadata`; filter on it with the store's `pruneTasksBy`.
27
+ - **Handler** (`PendingTaskHandler`) — per `type`, defines `check(task)` that polls your
28
+ backend and returns `{ status: "pending" | "success" | "failure", progress?, data? }` —
29
+ `data` is a free-form payload (a link, a message, whatever your `onResult` needs; see
30
+ below), plus per-type tuning (`pollIntervalMs`, `ttlMs`, `finalCheckOnExpiry`,
31
+ `silentOnSuccess`/`silentOnFailure`).
32
+ - **Registry** (`PendingTaskRegistry`) — a plain `{ [type]: handler }` map.
33
+ - **Store** — a zustand store, persisted to `localStorage`, holding the task list.
34
+ - **Poller** (`PendingTaskPoller`) — the engine: scans tasks on an interval, calls the
35
+ matching handler, and resolves each task to `success`/`failure` (dispatched via
36
+ `onResult`), to `error` (dispatched unless `silentOnFailure`) when `check()` itself kept
37
+ throwing until `maxFailureCount`, or silently to `expired` when the TTL ran out first —
38
+ `error`/`expired` are the engine's own doing, never something a handler returns itself.
39
+
40
+ ## Usage (core, no React)
41
+
42
+ ```ts
43
+ import { createPendingTaskStore, createPendingTaskRegistryBinding, PendingTaskPoller } from "pending-task-kit"
44
+
45
+ type TaskType = "search" | "exportJob"
46
+
47
+ const store = createPendingTaskStore<TaskType>({ storageKey: "my-app-pending-tasks" })
48
+
49
+ const registry = {
50
+ search: {
51
+ // The engine has no notion of auth — check() is a plain closure, so it captures
52
+ // whatever auth your app uses (a token, a cookie-based fetch, etc.) itself.
53
+ check: async (task) => {
54
+ const token = myAuthStore.getState().token
55
+ const res = await fetch(`/api/search/${task.taskId}`, { headers: { Authorization: `Bearer ${token}` } })
56
+ const data = await res.json()
57
+ // The backend's own `data.status` vocabulary is whatever that API defines — it has no
58
+ // relationship to the status you return here.
59
+ if (data.status === "done") return { status: "success", data: { href: `/results/${task.taskId}` } }
60
+ if (data.status === "error") return { status: "failure", data: { message: data.error } }
61
+ return { status: "pending", progress: { percent: data.percent } }
62
+ },
63
+ pollIntervalMs: 3_000,
64
+ ttlMs: 5 * 60_000,
65
+ },
66
+ }
67
+
68
+ const { addTask, addTaskIfMissing } = createPendingTaskRegistryBinding(store, registry)
69
+
70
+ // Start tracking a task right after kicking off the async job. If tasks need to be scoped
71
+ // to the signed-in user/tenant, put that in `metadata` — see "Scoping tasks" below.
72
+ addTask({
73
+ id: `search-${searchId}`,
74
+ type: "search",
75
+ taskId: searchId,
76
+ startedAt: Date.now(),
77
+ metadata: { userId },
78
+ })
79
+
80
+ // Drive the polling loop (module-level singleton). Call start()/stop() around your own
81
+ // auth lifecycle — e.g. start() once logged in, stop() on logout.
82
+ const poller = new PendingTaskPoller({
83
+ store,
84
+ registry,
85
+ onResult: (detail) => {
86
+ const data = detail.data as { href?: string; message?: string } | undefined
87
+ if (detail.status === "success") showToast(data?.message ?? "Done", { href: data?.href })
88
+ if (detail.status === "failure") showToast(data?.message ?? "Failed", { variant: "error" })
89
+ // detail.status can also be "error" (check() itself kept failing) — decide separately
90
+ // whether that deserves its own message; "expired" never reaches onResult at all.
91
+ },
92
+ onCheckError: (error) => {
93
+ // Return true for an error that means "stop this tick, don't count it as a normal
94
+ // failure" — e.g. the session ended. The engine doesn't know what an auth error looks
95
+ // like; you decide.
96
+ if (isUnauthorizedError(error)) {
97
+ poller.stop()
98
+ return true
99
+ }
100
+ },
101
+ })
102
+ poller.start()
103
+ ```
104
+
105
+ ## Usage (React binding)
106
+
107
+ ```tsx
108
+ import { usePendingTaskPoller } from "pending-task-kit/react"
109
+
110
+ function PendingTaskNotifier() {
111
+ const token = useAuthStore((s) => s.token)
112
+
113
+ usePendingTaskPoller({
114
+ store,
115
+ registry,
116
+ enabled: !!token, // the hook has no notion of auth — you decide when polling should run
117
+ onResult: (detail) => {
118
+ /* show a toast / navigate / invalidate a query cache */
119
+ },
120
+ })
121
+
122
+ return null
123
+ }
124
+ ```
125
+
126
+ Mount `<PendingTaskNotifier />` once near your app root.
127
+
128
+ ## Cross-tab poll-leader election (on by default)
129
+
130
+ When multiple tabs share the same store — they already do, since tasks sync across tabs via
131
+ `storage` events — only one of them actually calls `handler.check()` for a given task at a
132
+ time; every other tab skips its own network work for that task entirely. This is
133
+ `crossTabPollLeaderElection`, on by default. It's safe to leave on for single-tab usage too:
134
+ an uncontested poller always successfully claims/renews its own lease, so nothing changes when
135
+ there's no other tab to contend with.
136
+
137
+ Leadership is a renewable, TTL-backed claim (`pollLeaseTtlMs`, default `pollTickMs * 4`), not
138
+ a held lock — a leader that stops renewing (closed, crashed, or frozen in the browser's
139
+ back/forward cache) can't block every other tab forever; the lease just expires and any other
140
+ open tab picks up leadership on its next tick. `stop()` also makes a best-effort attempt to
141
+ release the lease right away if held (fire-and-forget, since `stop()` itself is synchronous —
142
+ an abrupt page unload can still lose the race), so a graceful shutdown usually doesn't make
143
+ other tabs wait out the full TTL either.
144
+
145
+ Since only the leader ever detects a task's completion, its result is relayed to every other
146
+ tab through a second localStorage key (`resultRelayKey`, default
147
+ `` `${storageKey}-result-relay` ``): each other tab fires its own `onResult` and (if that tab
148
+ also has `dispatchDomEvent` on) its own `CustomEvent`, from the relayed data, the same as if it
149
+ had detected the completion itself. If you've composed `claimResultOnce` (see the next
150
+ section), the relayed dispatch on every other tab goes through that same gate as the leader's
151
+ own local one — combining both gets you "only the leader polls" *and* "at most one tab ends up
152
+ notifying," consistently, regardless of which tab happened to detect the result.
153
+
154
+ Only one `PendingTaskPoller` instance should exist per tab per store — a `storage` event never
155
+ fires back in the tab that made the write, so a second poller instance sharing the same store
156
+ in the *same* tab would never receive this relay (or the task-list sync above) at all.
157
+
158
+ If a relayed result could belong to a session that's ended in *this* tab by the time it
159
+ arrives (a different account signed in, a logout) and re-surfacing it here would be wrong,
160
+ gate the receiving side with `acceptRelayedResult`:
161
+
162
+ ```ts
163
+ const poller = new PendingTaskPoller({
164
+ store,
165
+ registry,
166
+ acceptRelayedResult: (detail) => detail.task.metadata?.userId === myAuthStore.getState().userId,
167
+ })
168
+ ```
169
+
170
+ Turn cross-tab leader election off entirely only if you have a specific reason every tab must
171
+ independently poll everything:
172
+
173
+ ```ts
174
+ const poller = new PendingTaskPoller({
175
+ store,
176
+ registry,
177
+ crossTabPollLeaderElection: false,
178
+ })
179
+ ```
180
+
181
+ ## Cross-tab duplicate-toast dedupe (optional)
182
+
183
+ `crossTabPollLeaderElection` above stops tabs from duplicating the *polling* itself, and a
184
+ fencing check keeps a slow `check()` call from letting a *different* tab also reach `finalize()`
185
+ for the same task after leadership has moved on mid-request. It doesn't guarantee `onResult`
186
+ fires exactly once system-wide on its own, though — narrower windows remain: your own
187
+ `claimResultOnce` callback awaiting something slow can itself let leadership move to another tab
188
+ before it resolves, which then independently completes the same task and calls its own
189
+ `claimResultOnce`; `withTabLock` degrades to no mutual exclusion at all in a browser without the
190
+ Web Locks API; and a lease write that silently fails (quota exceeded, private-mode Safari) can,
191
+ rarely, let two tabs both believe they're leader. If several tabs can end up processing the same
192
+ completion (any of those, or a forced re-login race), compose the included primitives via
193
+ `claimResultOnce` — this also gates the *relayed* dispatch on every other tab (see above), so it
194
+ gives you a true system-wide guarantee even with leader election on:
195
+
196
+ ```ts
197
+ import { withTabLock, createTtlDedupeCache } from "pending-task-kit"
198
+
199
+ const notified = createTtlDedupeCache("my-app-pending-task-notified", 24 * 60 * 60 * 1000)
200
+
201
+ const poller = new PendingTaskPoller({
202
+ // ...
203
+ claimResultOnce: (task) =>
204
+ withTabLock(`pending-task:${task.id}`, () =>
205
+ notified.claim(`${task.id}:${task.startedAt}`),
206
+ ),
207
+ })
208
+ ```
209
+
210
+ Key the claim on `` `${task.id}:${task.startedAt}` ``, not on `task.id` alone. `id` is
211
+ documented as "stable, globally-unique — re-adding a task with the same id replaces it" (see
212
+ the `PendingTask.id` doc comment), so the *same* id can legitimately front several independent
213
+ runs over time (e.g. a user re-triggering the same paid action twice in one day). A cache
214
+ keyed on bare `task.id` doesn't distinguish those runs: the TTL window has to outlive one
215
+ run's own lifetime — a second tab can legitimately reach the same completion late (a slow
216
+ `claimResultOnce` await, a frozen tab waking up, an expiry-time `finalCheckOnExpiry`), so the
217
+ record must still be there when it does — which means it also spans across a second, unrelated
218
+ run's completion: that second completion's `onResult`/relayed dispatch gets silently swallowed
219
+ as if it were a duplicate of the first. `startedAt` is written fresh each time a *new* run is
220
+ added (see the `addTask` doc comment's replace-on-same-id note), while two tabs racing over
221
+ the *same* run still see the same `startedAt` — so appending it narrows the dedupe to "this
222
+ run" without reopening the cross-tab race this section exists to close.
223
+
224
+ If whatever `claimResultOnce` gates on (or `metadata`/`data` fields in the tasks it tracks) can
225
+ carry PII, call `notified.clear()` on explicit logout the same way you'd call the store's
226
+ `clearAllTasks()` — see "Scoping tasks" below.
227
+
228
+ ## Scoping tasks (e.g. to a user/tenant)
229
+
230
+ `PendingTask` has no dedicated "owner" field — the engine doesn't know or care what a task
231
+ "belongs to". If your app needs that (most do), put an identifier of your choosing in
232
+ `metadata` when you `addTask`, and use the store's `pruneTasksBy(predicate)` to drop tasks
233
+ that don't match it — e.g. after a different account logs in:
234
+
235
+ ```ts
236
+ store.getState().pruneTasksBy((task) => task.metadata?.userId === currentUserId)
237
+ ```
238
+
239
+ This package also has no opinion on login/logout more broadly — call `pruneTasksBy` after
240
+ login and `clearAllTasks()` on explicit logout yourself, in whatever auth store you use.
241
+ Skipping `clearAllTasks()` on a *forced* (e.g. 401) logout lets in-flight tasks (like a pending
242
+ payment confirmation) survive a quick re-login — that's an intentional choice to make, not a
243
+ default this package bakes in.
244
+
245
+ If a task's `metadata` or a handler's `PendingTaskCheckResult.data` can carry PII, remember
246
+ this can now also transiently sit in the `resultRelayKey` localStorage entry (see "Cross-tab
247
+ poll-leader election" above) once `crossTabPollLeaderElection` is on — it's overwritten by the
248
+ next result, but nothing clears it proactively. Clear it on explicit logout the same way you'd
249
+ call the store's `clearAllTasks()` or a dedupe cache's `clear()`:
250
+
251
+ ```ts
252
+ import { clearResultRelay } from "pending-task-kit"
253
+
254
+ clearResultRelay(resultRelayKey) // same key you passed, or `${storageKey}-result-relay`
255
+ ```
256
+
257
+ ## What's deliberately out of scope
258
+
259
+ - Toast/notification UI (`onResult` is a plain callback — bring your own).
260
+ - Navigation, messages, action labels, cache invalidation — `PendingTaskCheckResult.data`
261
+ is a free-form payload for all of it; `status` is the only field the engine itself reads.
262
+ - Auth/session management entirely — there's no token concept anywhere in the engine.
263
+ `handler.check(task)` is a plain closure, so it captures whatever auth your app uses itself;
264
+ `start()`/`stop()` (or the React binding's `enabled`) are how you gate polling on being
265
+ logged in; `onCheckError` lets you recognize an auth failure and react to it (e.g. call
266
+ `stop()`) without the engine knowing what "unauthorized" means.
@@ -0,0 +1,250 @@
1
+ # pending-task-kit
2
+
3
+ 框架无关的长任务跟踪/轮询引擎(AI 生成任务、异步搜索、支付确认、批量操作等),
4
+ 用于处理那些"可能比发起它的页面活得更久"的后台任务,附带一个可选的 React 绑定。
5
+
6
+ 从某个生产环境应用的 `PendingTaskNotifier` 子系统中抽取而来。通知渠道
7
+ (toast、跳转、缓存失效)被刻意排除在这个包之外——你需要自己通过 `onResult` 接入。
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ pnpm add pending-task-kit zustand
13
+ # React 绑定还需要 react 作为 peer dependency(在 React 应用里通常已经有了)
14
+ ```
15
+
16
+ ## 核心概念
17
+
18
+ - **Task**(`PendingTask`)—— `{ id, type, taskId, startedAt, ttlMs?, metadata? }`,代表一条
19
+ 会被持续跟踪、直到 resolve 或过期的记录。它没有专门的"owner"/"title"/"link"字段——引擎
20
+ 自身只会读取 `id`/`type`/`taskId`/`startedAt`/`lastCheckedAt`/`failureCount`/`ttlMs`(后两
21
+ 者由引擎自己维护,之所以作为独立字段,正是为了不与你自己数据里可能用到的 key 冲突)。
22
+ 你的应用想附加到任务上的其它任何东西——展示用的标题、链接、用来做归属的用户/租户
23
+ id——都放进完全自由形式的 `metadata` 里;配合 store 的 `pruneTasksBy` 按它过滤。
24
+ - **Handler**(`PendingTaskHandler`)—— 按 `type` 划分,定义 `check(task)`,负责轮询你的
25
+ 后端并返回 `{ status: "pending" | "success" | "failure", progress?, data? }`——`data` 是
26
+ 自由形式的负载(一个链接、一条消息,或任何你 `onResult` 需要的东西;见下文),此外还有
27
+ 按类型调节的选项(`pollIntervalMs`、`ttlMs`、`finalCheckOnExpiry`、
28
+ `silentOnSuccess`/`silentOnFailure`)。
29
+ - **Registry**(`PendingTaskRegistry`)—— 一个普通的 `{ [type]: handler }` 映射。
30
+ - **Store** —— 一个 zustand store,持久化到 `localStorage`,保存任务列表。
31
+ - **Poller**(`PendingTaskPoller`)—— 引擎本体:按间隔扫描任务,调用对应的 handler,并将
32
+ 每个任务归结为 `success`/`failure`(通过 `onResult` 派发)、`error`(当 `check()` 本身
33
+ 持续抛错直到达到 `maxFailureCount` 时派发,除非设置了 `silentOnFailure`),或者在 TTL
34
+ 先耗尽时静默归结为 `expired`——`error`/`expired` 都是引擎自己的判断,handler 本身永远
35
+ 不会返回这两种状态。
36
+
37
+ ## 用法(核心,不涉及 React)
38
+
39
+ ```ts
40
+ import { createPendingTaskStore, createPendingTaskRegistryBinding, PendingTaskPoller } from "pending-task-kit"
41
+
42
+ type TaskType = "search" | "exportJob"
43
+
44
+ const store = createPendingTaskStore<TaskType>({ storageKey: "my-app-pending-tasks" })
45
+
46
+ const registry = {
47
+ search: {
48
+ // 引擎不了解身份认证——check() 只是个普通闭包,所以它会自然捕获你应用自己用的那套
49
+ // 认证方式(一个 token、基于 cookie 的 fetch,等等)。
50
+ check: async (task) => {
51
+ const token = myAuthStore.getState().token
52
+ const res = await fetch(`/api/search/${task.taskId}`, { headers: { Authorization: `Bearer ${token}` } })
53
+ const data = await res.json()
54
+ // 后端自己的 data.status 取值是那个 API 自行定义的——和这里返回的 status 没有任何关系。
55
+ if (data.status === "done") return { status: "success", data: { href: `/results/${task.taskId}` } }
56
+ if (data.status === "error") return { status: "failure", data: { message: data.error } }
57
+ return { status: "pending", progress: { percent: data.percent } }
58
+ },
59
+ pollIntervalMs: 3_000,
60
+ ttlMs: 5 * 60_000,
61
+ },
62
+ }
63
+
64
+ const { addTask, addTaskIfMissing } = createPendingTaskRegistryBinding(store, registry)
65
+
66
+ // 在异步任务发起后立刻开始跟踪它。如果任务需要按登录用户/租户做归属区分,把这个信息
67
+ // 放进 metadata——见下文"任务归属范围"一节。
68
+ addTask({
69
+ id: `search-${searchId}`,
70
+ type: "search",
71
+ taskId: searchId,
72
+ startedAt: Date.now(),
73
+ metadata: { userId },
74
+ })
75
+
76
+ // 驱动轮询循环(模块级单例)。围绕你自己的认证生命周期调用 start()/stop()——比如登录后
77
+ // start(),登出时 stop()。
78
+ const poller = new PendingTaskPoller({
79
+ store,
80
+ registry,
81
+ onResult: (detail) => {
82
+ const data = detail.data as { href?: string; message?: string } | undefined
83
+ if (detail.status === "success") showToast(data?.message ?? "Done", { href: data?.href })
84
+ if (detail.status === "failure") showToast(data?.message ?? "Failed", { variant: "error" })
85
+ // detail.status 也可能是 "error"(check() 自己持续失败)——是否需要单独的提示文案由你
86
+ // 决定;"expired" 永远不会到达 onResult。
87
+ },
88
+ onCheckError: (error) => {
89
+ // 对于那种意味着"终止本轮 tick、不计入正常失败次数"的错误返回 true——比如会话已过期。
90
+ // 引擎不知道认证错误长什么样,由你来判断。
91
+ if (isUnauthorizedError(error)) {
92
+ poller.stop()
93
+ return true
94
+ }
95
+ },
96
+ })
97
+ poller.start()
98
+ ```
99
+
100
+ ## 用法(React 绑定)
101
+
102
+ ```tsx
103
+ import { usePendingTaskPoller } from "pending-task-kit/react"
104
+
105
+ function PendingTaskNotifier() {
106
+ const token = useAuthStore((s) => s.token)
107
+
108
+ usePendingTaskPoller({
109
+ store,
110
+ registry,
111
+ enabled: !!token, // hook 不了解身份认证——由你决定何时应该轮询
112
+ onResult: (detail) => {
113
+ /* 弹 toast / 跳转 / 让某个 query 缓存失效 */
114
+ },
115
+ })
116
+
117
+ return null
118
+ }
119
+ ```
120
+
121
+ 在应用根部挂载一次 `<PendingTaskNotifier />` 即可。
122
+
123
+ ## 跨标签页轮询选主(默认开启)
124
+
125
+ 多个标签页共享同一个 store 时(它们本来就是共享的——任务本身已经通过 `storage` 事件
126
+ 跨标签页同步),同一时刻只会有一个标签页真正调用 `handler.check()`;其它标签页对这个
127
+ task 完全不发起任何网络请求。这就是 `crossTabPollLeaderElection`,默认开启。单标签页场景
128
+ 下开着它也没有副作用——没有别的标签页竞争时,一个 poller 永远能成功认领/续租自己的
129
+ 租约,行为不受影响。
130
+
131
+ 选主用的是可续租、带 TTL 的声明(`pollLeaseTtlMs`,默认 `pollTickMs * 4`),不是一直持有
132
+ 的锁——停止续租的 leader(被关闭、崩溃,或者被浏览器冻结进前进后退缓存)不会永久卡住
133
+ 其它标签页,租约会自然过期,任意还开着的标签页在下一次 tick 就能接管。`stop()` 也会
134
+ 尽力(best-effort)在持有租约时立刻发起释放——这是 fire-and-forget 的,因为 `stop()`
135
+ 本身是同步 API,页面正好在这时被卸载的话仍可能来不及真正写完——优雅关闭的场景下,
136
+ 通常其它标签页不需要等满整个 TTL。
137
+
138
+ 因为只有 leader 会检测到任务完成,它的结果会通过第二个 localStorage key
139
+ (`resultRelayKey`,默认 `` `${storageKey}-result-relay` ``)广播给其它每个标签页:每个
140
+ 标签页会用广播过来的数据触发自己的 `onResult`,以及(如果那个标签页也开着
141
+ `dispatchDomEvent`)自己的 `CustomEvent`,效果跟那个标签页自己检测到完成一样。如果你
142
+ 组合使用了 `claimResultOnce`(见下一节),其它标签页收到广播后的派发也会经过和 leader
143
+ 本地派发相同的这道门——两者一起用,就能同时得到"只有 leader 轮询"和"最多一个标签页
144
+ 最终发出通知"这两个效果,且不受哪个标签页先检测到结果的影响。
145
+
146
+ 同一个标签页、同一个 store 下应该只存在一个 `PendingTaskPoller` 实例——`storage` 事件
147
+ 永远不会在发起写入的那个标签页自己身上触发,所以同一个标签页里如果有第二个 poller 实例
148
+ 共享同一个 store,它将完全收不到这份广播(也收不到上面提到的任务列表同步)。
149
+
150
+ 如果一条广播过来的结果,到达这个标签页时可能已经"过期"(比如中途换了个账号登录、或者
151
+ 已经登出了),继续在这个标签页里重新 dispatch 出去就不对了,可以用 `acceptRelayedResult`
152
+ 给接收端加一道判断:
153
+
154
+ ```ts
155
+ const poller = new PendingTaskPoller({
156
+ store,
157
+ registry,
158
+ acceptRelayedResult: (detail) => detail.task.metadata?.userId === myAuthStore.getState().userId,
159
+ })
160
+ ```
161
+
162
+ 只有在你确实需要让每个标签页各自轮询全部任务时,才整体关掉跨标签页选主:
163
+
164
+ ```ts
165
+ const poller = new PendingTaskPoller({
166
+ store,
167
+ registry,
168
+ crossTabPollLeaderElection: false,
169
+ })
170
+ ```
171
+
172
+ ## 跨标签页去重提示(可选)
173
+
174
+ 上面的 `crossTabPollLeaderElection` 解决的是"轮询本身不要重复";有了 fencing 校验之后,
175
+ 一次很慢的 `check()` 也不会再让 leadership 转手后的另一个标签页也走到同一个任务的
176
+ `finalize()`。但这不代表 `onResult` 在全局范围内保证只触发一次——还残留几个更窄的窗口:
177
+ 你自己的 `claimResultOnce` 回调如果本身很慢,它 await 的过程中 leadership 仍可能转手到
178
+ 另一个标签页,那个标签页独立完成同一个任务后,会调用它自己的 `claimResultOnce`;在没有
179
+ Web Locks API 的浏览器里,`withTabLock` 会退化为完全不加锁;租约写入偶尔静默失败(比如
180
+ 配额超限、Safari 隐私模式)时,也可能让两个标签页都以为自己是 leader。如果多个标签页
181
+ 可能同时处理同一个任务的完成事件(以上任意一种竞态,或者强制重新登录导致的竞态),可以
182
+ 通过 `claimResultOnce` 组合内置的两个原语——它也会覆盖到其它标签页收到广播后的那次派发
183
+ (见上文),所以即使开着选主,也能拿到真正的全局保证:
184
+
185
+ ```ts
186
+ import { withTabLock, createTtlDedupeCache } from "pending-task-kit"
187
+
188
+ const notified = createTtlDedupeCache("my-app-pending-task-notified", 24 * 60 * 60 * 1000)
189
+
190
+ const poller = new PendingTaskPoller({
191
+ // ...
192
+ claimResultOnce: (task) =>
193
+ withTabLock(`pending-task:${task.id}`, () =>
194
+ notified.claim(`${task.id}:${task.startedAt}`),
195
+ ),
196
+ })
197
+ ```
198
+
199
+ 去重的 key 要用 `` `${task.id}:${task.startedAt}` ``,不能只用 `task.id`。`id` 的文档
200
+ (见 `PendingTask.id` 的 doc 注释)写的是"稳定、全局唯一——用同一个 id 重新 addTask 会替换掉
201
+ 原来那条",也就是说同一个 id 完全可以在不同时间点先后对应好几轮互不相关的独立任务
202
+ (比如用户同一天内两次触发同一个付费动作)。只按裸 `task.id` 去重分不清这几轮:
203
+ 去重记录的 TTL 必须比单轮任务自己的生命周期还长——另一个标签页完全可能更晚才到达同一次完成
204
+ (`claimResultOnce` 等待耗时、冻结的标签页被唤醒、到期时的 `finalCheckOnExpiry`),记录必须
205
+ 在那时还在——这就意味着它同样会覆盖到第二轮、完全独立的那次完成:第二轮的 `onResult`/广播
206
+ 派发会被当成"第一轮的重复"直接吞掉。`startedAt` 是每次真正新开一轮任务时才写入的时间戳
207
+ (见 `addTask` doc 注释里"同 id 会替换"的说明),而两个标签页在竞争**同一轮**任务时看到的
208
+ `startedAt` 是相同的——拼上它能把去重粒度收紧到"这一轮",既不会误伤下一轮独立的完成结果,
209
+ 也不会打开这一节本来要堵上的跨标签页竞态口子。
210
+
211
+ 如果 `claimResultOnce` 判断依据的内容(或者它跟踪的任务的 `metadata`/`data` 字段)可能
212
+ 带 PII,在用户主动登出时也调用一下 `notified.clear()`,跟下面"任务归属范围"一节里
213
+ 调用 store 的 `clearAllTasks()` 是同一个道理。
214
+
215
+ ## 任务归属范围(比如按用户/租户区分)
216
+
217
+ `PendingTask` 没有专门的"owner"字段——引擎不知道也不关心一个任务"属于谁"。如果你的应用
218
+ 需要这个(大多数都需要),在 `addTask` 时把你自选的标识符放进 `metadata`,再用 store 的
219
+ `pruneTasksBy(predicate)` 丢弃不匹配的任务——比如切换到另一个账号登录之后:
220
+
221
+ ```ts
222
+ store.getState().pruneTasksBy((task) => task.metadata?.userId === currentUserId)
223
+ ```
224
+
225
+ 这个包在更广泛的登录/登出流程上也没有任何主张——请在你自己的认证 store 里,登录后自行
226
+ 调用 `pruneTasksBy`,在用户主动登出时自行调用 `clearAllTasks()`。在*被动*登出(比如收到
227
+ 401)时不调用 `clearAllTasks()`,可以让正在进行中的任务(比如一次待确认的支付)在快速
228
+ 重新登录后依然存活——这是一个需要你主动做出的选择,而不是这个包默认内置的行为。
229
+
230
+ 如果任务的 `metadata` 或 handler 返回的 `PendingTaskCheckResult.data` 可能带 PII,记得
231
+ 开启 `crossTabPollLeaderElection` 后,这些内容也会短暂地留在 `resultRelayKey` 这个
232
+ localStorage 条目里(见上面"跨标签页轮询选主"一节)——下一次结果会覆盖它,但没有任何
233
+ 东西会主动清理它。在用户主动登出时清掉它,跟调用 store 的 `clearAllTasks()`、去重缓存的
234
+ `clear()` 是同一个道理:
235
+
236
+ ```ts
237
+ import { clearResultRelay } from "pending-task-kit"
238
+
239
+ clearResultRelay(resultRelayKey) // 用你传入的那个 key,没传的话就是 `${storageKey}-result-relay`
240
+ ```
241
+
242
+ ## 刻意排除在范围之外的东西
243
+
244
+ - Toast/通知 UI(`onResult` 只是一个普通回调——UI 部分自己实现)。
245
+ - 跳转、消息文案、操作按钮文案、缓存失效——`PendingTaskCheckResult.data` 是承载这一切的
246
+ 自由形式负载;`status` 是引擎自身唯一会读取的字段。
247
+ - 完全不涉及认证/会话管理——引擎里任何地方都没有 token 的概念。`handler.check(task)`
248
+ 只是个普通闭包,所以它会自然捕获你应用自己用的认证方式;`start()`/`stop()`(或 React
249
+ 绑定里的 `enabled`)是你用来控制"是否应该轮询"的开关;`onCheckError` 让你能够识别出一次
250
+ 认证失败并做出反应(比如调用 `stop()`),而引擎本身完全不需要知道"未授权"是什么意思。