@steve02081504/fount-p2p 0.0.19 → 0.0.20

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.
@@ -3,15 +3,24 @@ import { randomUUID } from 'node:crypto'
3
3
  import { bytesToBase64 } from '../core/bytes_codec.mjs'
4
4
  import {
5
5
  MAX_PENDING_CHUNK_FETCHES,
6
- pendingChunkFetches,
7
6
  registerChunkFetchWait,
8
7
  } from '../federation/chunk_fetch_pending.mjs'
8
+ import { ms } from '../utils/duration.mjs'
9
+ import { createInflightTable } from '../utils/inflight_table.mjs'
9
10
 
10
11
  import { verifiedChunkBytes } from './chunk_fetch_verify.mjs'
11
12
  import { fetchFederationChunk, resolveNodeHash } from './chunk_provider_registry.mjs'
12
13
  import { getChunk, hasChunk, putChunk } from './chunk_store.mjs'
13
14
  import { fanoutFedFetch } from './fetch_fanout.mjs'
14
15
 
16
+ const DEFAULT_CHUNK_FETCH_TIMEOUT_MS = ms('8s')
17
+
18
+ /** 同 username+hash 共享一次 fanout;队满只丢已超基础超时的队首。 */
19
+ const chunkInflight = createInflightTable({
20
+ maxSize: MAX_PENDING_CHUNK_FETCHES,
21
+ baseTimeoutMs: DEFAULT_CHUNK_FETCH_TIMEOUT_MS,
22
+ })
23
+
15
24
  /**
16
25
  * @typedef {{
17
26
  * username: string,
@@ -42,19 +51,27 @@ export async function fetchChunk(context) {
42
51
  }
43
52
  }
44
53
 
45
- if (pendingChunkFetches.size >= MAX_PENDING_CHUNK_FETCHES) return null
54
+ const inflightKey = `${username}\0${hash}`
55
+ const shared = chunkInflight.acquire(inflightKey, () => {
56
+ const requestId = randomUUID()
57
+ const wait = registerChunkFetchWait(requestId, hash, DEFAULT_CHUNK_FETCH_TIMEOUT_MS)
58
+ void (async () => {
59
+ try {
60
+ const { nodeHash } = await resolveNodeHash(username)
61
+ await fanoutFedFetch(username, 'fed_chunk_get', {
62
+ requestId,
63
+ nodeHash,
64
+ chunkHash: hash,
65
+ ownerEntityHash: context.ownerEntityHash,
66
+ })
67
+ }
68
+ catch { /* pending wait 超时/cancel 负责 settle */ }
69
+ })()
70
+ return { done: wait.done, cancel: wait.cancel }
71
+ })
72
+ if (!shared) return null
46
73
 
47
- const requestId = randomUUID()
48
- const { done } = registerChunkFetchWait(requestId, hash, 8000)
49
- const { nodeHash } = await resolveNodeHash(username)
50
- const payload = {
51
- requestId,
52
- nodeHash,
53
- chunkHash: hash,
54
- ownerEntityHash: context.ownerEntityHash,
55
- }
56
- await fanoutFedFetch(username, 'fed_chunk_get', payload)
57
- const result = await done
74
+ const result = await shared
58
75
  const verified = verifiedChunkBytes(hash, result)
59
76
  if (verified) {
60
77
  await putChunk(hash, verified)
@@ -3,11 +3,12 @@ import { randomUUID } from 'node:crypto'
3
3
  import {
4
4
  manifestFetchExpectedKey,
5
5
  MAX_PENDING_MANIFEST_FETCHES,
6
- pendingManifestFetches,
7
6
  registerManifestFetchWait,
8
7
  } from '../federation/manifest_fetch_pending.mjs'
9
8
  import { isWritableLocalEntity } from '../node/identity.mjs'
10
9
  import { getEntityStore } from '../node/instance.mjs'
10
+ import { ms } from '../utils/duration.mjs'
11
+ import { createInflightTable } from '../utils/inflight_table.mjs'
11
12
 
12
13
  import { resolveNodeHash } from './chunk_provider_registry.mjs'
13
14
  import { loadFileManifest, saveFileManifest } from './evfs.mjs'
@@ -15,11 +16,18 @@ import { fanoutFedFetch } from './fetch_fanout.mjs'
15
16
  import { normalizeFileManifest } from './manifest.mjs'
16
17
  import { shouldPreferIncomingPublicManifest } from './public_manifest.mjs'
17
18
 
18
- const DEFAULT_MANIFEST_FETCH_TIMEOUT_MS = 8000
19
+ const DEFAULT_MANIFEST_FETCH_TIMEOUT_MS = ms('8s')
20
+
21
+ /** 同 username+owner+path 共享一次 fanout;队满只丢已超基础超时的队首。 */
22
+ const manifestInflight = createInflightTable({
23
+ maxSize: MAX_PENDING_MANIFEST_FETCHES,
24
+ baseTimeoutMs: DEFAULT_MANIFEST_FETCH_TIMEOUT_MS,
25
+ })
19
26
 
20
27
  /**
21
28
  * 拉取公开 manifest;默认不写盘。`cache: true` 或 `cachePublicManifest` 才缓存。
22
29
  * 本地已有 publicSig 时仍会 fanout 再校验,按 publishedAt 择新;超时则回退本地。
30
+ * 同 key in-flight 去重;调用方外层超时不 abort,后台继续填缓存。
23
31
  * @param {{ username: string, ownerEntityHash: string, logicalPath: string, cache?: boolean, timeoutMs?: number }} context - 拉取上下文
24
32
  * @returns {Promise<import('./manifest.mjs').FileManifest | null>} 验签后的 manifest,失败为 null
25
33
  */
@@ -29,30 +37,37 @@ export async function fetchPublicManifest(context) {
29
37
  const { username } = context
30
38
  if (!ownerEntityHash || !logicalPath || !username) return null
31
39
 
32
- const local = await loadFileManifest(ownerEntityHash, logicalPath)
33
- const hasLocalPublic = local?.transferKeyDescriptor?.type === 'public' && !!local?.meta?.publicSig
34
-
35
- if (pendingManifestFetches.size >= MAX_PENDING_MANIFEST_FETCHES)
36
- return hasLocalPublic ? local : null
37
-
38
40
  const timeoutMs = Number(context.timeoutMs) > 0
39
41
  ? Number(context.timeoutMs)
40
42
  : DEFAULT_MANIFEST_FETCH_TIMEOUT_MS
41
- const requestId = randomUUID()
42
- const { done } = registerManifestFetchWait(
43
- requestId,
44
- manifestFetchExpectedKey(ownerEntityHash, logicalPath),
45
- timeoutMs,
46
- )
47
- const { nodeHash } = await resolveNodeHash(username)
48
- const payload = {
49
- requestId,
50
- nodeHash,
51
- ownerEntityHash,
52
- logicalPath,
53
- }
54
- await fanoutFedFetch(username, 'fed_manifest_get', payload)
55
- const result = await done
43
+ const expectedKey = manifestFetchExpectedKey(ownerEntityHash, logicalPath)
44
+ const inflightKey = `${username}\0${expectedKey}`
45
+
46
+ // 先挂 in-flight(同步),再读本地 — 避免并发调用在 await 间隙各自 start
47
+ const localPromise = loadFileManifest(ownerEntityHash, logicalPath)
48
+ const shared = manifestInflight.acquire(inflightKey, () => {
49
+ const requestId = randomUUID()
50
+ const wait = registerManifestFetchWait(requestId, expectedKey, timeoutMs)
51
+ void (async () => {
52
+ try {
53
+ const { nodeHash } = await resolveNodeHash(username)
54
+ await fanoutFedFetch(username, 'fed_manifest_get', {
55
+ requestId,
56
+ nodeHash,
57
+ ownerEntityHash,
58
+ logicalPath,
59
+ })
60
+ }
61
+ catch { /* pending wait 超时/cancel 负责 settle */ }
62
+ })()
63
+ return { done: wait.done, cancel: wait.cancel }
64
+ })
65
+
66
+ const local = await localPromise
67
+ const hasLocalPublic = local?.transferKeyDescriptor?.type === 'public' && !!local?.meta?.publicSig
68
+ if (!shared) return hasLocalPublic ? local : null
69
+
70
+ const result = await shared
56
71
 
57
72
  if (result && (!hasLocalPublic || shouldPreferIncomingPublicManifest(local, result))) {
58
73
  if (context.cache === true)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -0,0 +1,95 @@
1
+ /**
2
+ * In-flight 去重表:同 key 复用并 touch 到队尾;队满时仅淘汰「已超过 baseTimeout」的队首。
3
+ * 双窗口:size >= maxSize 且 entry 年龄 >= baseTimeoutMs 才 cancel。
4
+ *
5
+ * @template T
6
+ * @param {{ maxSize: number, baseTimeoutMs: number, now?: () => number }} options 容量与基础超时
7
+ * @returns {{
8
+ * size: () => number,
9
+ * has: (key: string) => boolean,
10
+ * acquire: (key: string, start: () => { done: Promise<T>, cancel: () => void }) => Promise<T> | null,
11
+ * clear: () => void,
12
+ * }} 表句柄
13
+ */
14
+ export function createInflightTable(options) {
15
+ const maxSize = Math.max(1, Math.floor(Number(options.maxSize) || 1))
16
+ const baseTimeoutMs = Math.max(0, Number(options.baseTimeoutMs) || 0)
17
+ const now = options.now || Date.now
18
+
19
+ /** @type {Map<string, { done: Promise<T>, cancel: () => void, startedAt: number }>} */
20
+ const map = new Map()
21
+
22
+ /**
23
+ * 队满时从队首取消已超时项。
24
+ * @returns {void}
25
+ */
26
+ function pruneAgedOverCap() {
27
+ const t = now()
28
+ while (map.size >= maxSize) {
29
+ const oldestKey = map.keys().next().value
30
+ const entry = map.get(oldestKey)
31
+ if (!entry || t - entry.startedAt < baseTimeoutMs) break
32
+ map.delete(oldestKey)
33
+ entry.cancel()
34
+ }
35
+ }
36
+
37
+ /**
38
+ * @param {string} key 逻辑键
39
+ * @param {{ done: Promise<T>, cancel: () => void, startedAt: number }} entry 条目
40
+ * @returns {void}
41
+ */
42
+ function track(key, entry) {
43
+ entry.done.finally(() => {
44
+ if (map.get(key) === entry) map.delete(key)
45
+ })
46
+ map.set(key, entry)
47
+ }
48
+
49
+ return {
50
+ /**
51
+ * @returns {number} 当前 in-flight 数
52
+ */
53
+ size: () => map.size,
54
+ /**
55
+ * @param {string} key 逻辑键
56
+ * @returns {boolean} 是否在飞
57
+ */
58
+ has: key => map.has(key),
59
+ /**
60
+ * 复用或启动;队满且无法淘汰超时项时返回 null(拒绝新开)。
61
+ * @param {string} key 逻辑键
62
+ * @param {() => { done: Promise<T>, cancel: () => void }} start 仅在未命中时调用
63
+ * @returns {Promise<T> | null} 共享 Promise,或拒绝新开
64
+ */
65
+ acquire(key, start) {
66
+ const existing = map.get(key)
67
+ if (existing) {
68
+ map.delete(key)
69
+ map.set(key, existing)
70
+ pruneAgedOverCap()
71
+ return existing.done
72
+ }
73
+
74
+ pruneAgedOverCap()
75
+ if (map.size >= maxSize) return null
76
+
77
+ const started = start()
78
+ const entry = {
79
+ done: started.done,
80
+ cancel: started.cancel,
81
+ startedAt: now(),
82
+ }
83
+ track(key, entry)
84
+ return entry.done
85
+ },
86
+ /**
87
+ * 取消全部并清空(测试用)。
88
+ * @returns {void}
89
+ */
90
+ clear() {
91
+ for (const entry of map.values()) entry.cancel()
92
+ map.clear()
93
+ },
94
+ }
95
+ }