From 43850aaed3aed330aae85d075e1b1ffe577f31ae Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 17:57:38 +0800 Subject: [PATCH 01/12] fix(gridfs): close remaining upload races and stream append safely Re-check files after beginResumableUpload inserts a session and never delete chunks of a file that became visible in that window. Roll a finalizing session back to pending after GRIDFS_FILE_EXISTS or GRIDFS_INTEGRITY_MISMATCH so public abort works. Align renameFile release-owner handling with upload so a cleanup failure cannot mask the real result. Re-read visibility after a recovery claim and fail closed when verifyVisibleFile cannot confirm a required visible file. Consume Readable appends incrementally (design A): claim first, honor backpressure, buffer only the residual plus the in-flight chunk, and reject trailing partial or oversize input during consumption. Add an inspect-only, paginated orphan reporter with no apply or delete path. Clarify buffering, large-file resumable guidance, and the caller-persisted verification contract in the English and Chinese docs. Record Search/Vector Search Atlas deployments as UNVERIFIED. --- docs/en/gridfs-recovery.md | 9 +- docs/en/gridfs.md | 10 +- docs/en/support-matrix.md | 2 +- docs/zh/gridfs-recovery.md | 10 ++ docs/zh/gridfs.md | 10 +- docs/zh/support-matrix.md | 2 +- .../maintenance/gridfs-inspect-orphans.cjs | 158 ++++++++++++++++++ scripts/maintenance/gridfs-recover.cjs | 17 +- .../validation/run-memory-server-matrix.cjs | 2 + src/capabilities/gridfs/operations.ts | 16 +- src/capabilities/gridfs/resumable.ts | 110 +++++++++--- src/capabilities/gridfs/stream-chunks.ts | 139 +++++++++++++++ .../gridfs/gridfs-inspect-orphans.test.ts | 120 +++++++++++++ .../gridfs/gridfs-remaining-issues.test.ts | 138 +++++++++++++++ .../gridfs/gridfs-stale-recovery.test.ts | 66 ++++++++ test/run-tests.cjs | 3 + test/unit/gridfs/gridfs.test.ts | 119 +++++++++++++ test/unit/gridfs/stream-chunks.test.ts | 148 ++++++++++++++++ 18 files changed, 1036 insertions(+), 43 deletions(-) create mode 100644 scripts/maintenance/gridfs-inspect-orphans.cjs create mode 100644 src/capabilities/gridfs/stream-chunks.ts create mode 100644 test/integration/gridfs/gridfs-inspect-orphans.test.ts create mode 100644 test/integration/gridfs/gridfs-remaining-issues.test.ts create mode 100644 test/unit/gridfs/stream-chunks.test.ts diff --git a/docs/en/gridfs-recovery.md b/docs/en/gridfs-recovery.md index 5409cf5..9031b07 100644 --- a/docs/en/gridfs-recovery.md +++ b/docs/en/gridfs-recovery.md @@ -6,7 +6,14 @@ The ownership registry is the per-bucket `.uploads` collection. A reservation excludes ordinary upload, uploadStream, resumable append/finalize/abort, delete, rename and orphan cleanup for the same ObjectId. A missing file document **does not** prove its chunks are orphaned: an ordinary upload may still be writing, or GridFS driver delete may have removed the files document before chunks. Recovering while an old writer can resume can destroy new data, even with a token-CAS. The maintenance process cannot prove cluster/process shutdown on its own. -The script `scripts/maintenance/gridfs-recover.cjs` uses a default read-only preview, exact state/token/timestamp checks, a persisted `recovering` ownership claim, verified preservation of committed visible chunks, and an append-only-by-intent audit collection `.ownership_recovery_audit`. It never performs a time-based lock steal. +The script `scripts/maintenance/gridfs-recover.cjs` uses a default read-only preview, exact state/token/timestamp checks, a persisted `recovering` ownership claim, verified preservation of committed visible chunks, and an append-only-by-intent audit collection `.ownership_recovery_audit`. It never performs a time-based lock steal. Dry-run plans may use a pre-claim visibility snapshot; **apply always re-reads `files` after the exclusive claim**. If visibility changed, or `verifyVisibleFile` cannot confirm a visible file that the plan required, the tool fails closed and leaves the `recovering` owner in place. + +To survey unknown-id leftover chunks without deleting anything, use the inspect-only reporter `scripts/maintenance/gridfs-inspect-orphans.cjs`. It walks `.chunks` in index order with range pagination, re-checks `files` and `uploads` for each `files_id` at classification time, and reports `visible_file`, `owned`, or `candidate_orphan`. It has no apply mode, does not delete, and does not use document age as a deletion criterion. It is not part of the public `GridFSBucket` API. + +```sh +export MONSQLIZE_RECOVERY_MONGODB_URI='' +node scripts/maintenance/gridfs-inspect-orphans.cjs --db=my_database --bucket=fs --page-size=200 +``` ## Deployment and rollback gates (required) diff --git a/docs/en/gridfs.md b/docs/en/gridfs.md index 85d6d5f..65e395e 100644 --- a/docs/en/gridfs.md +++ b/docs/en/gridfs.md @@ -46,7 +46,7 @@ Calling either method before `connect()` throws `NOT_CONNECTED`. `upload` defaults to `ifExists: 'error'`. - When `sha256` is set, the conflict key is `metadata.sha256`. The digest must be 64 lowercase hex characters. `Buffer` and `Uint8Array` payloads are hashed before any GridFS write. A mismatch throws `GRIDFS_INTEGRITY_MISMATCH` and leaves no visible file. -- A `Readable` passed with `sha256` is buffered completely, then hashed, before any write. Large files should use the resumable API instead. +- A `Readable` passed with `sha256` is buffered completely, then hashed, before any write. That is required to honor the contract that a failed verification leaves no visible file. `upload()` without `sha256` can stream, but the whole payload is still a single commit. For large files, use the resumable API with `appendChunks` and a `Readable` whose `highWaterMark` stays modest; do not pass a multi-gigabyte `Buffer` or a fully-buffered `upload({ sha256 })`. - `skip` with `sha256` returns the newest visible file with that digest. `skip` without `sha256` returns the newest visible file with the same filename. `duplicate` always creates another file. - A `metadata.sha256` value supplied inside `metadata` is caller data and is not verified. To request byte verification with `upload()`, use the top-level `sha256` option. - `uploadStream` stores its top-level `sha256` option in metadata only. It does not check the bytes and it does not accept `ifExists`. @@ -75,13 +75,13 @@ const info = await bucket.finalizeResumableUpload(session.uploadId); await bucket.abortResumableUpload(session.uploadId); ``` -`beginResumableUpload` requires `length` and `sha256`. An id that already exists in `files` or `uploads` throws `GRIDFS_FILE_EXISTS`. +`beginResumableUpload` requires `length` and `sha256`. An id that already exists in `files` or `uploads` throws `GRIDFS_FILE_EXISTS`. Those prechecks are not a lock: the session insert is the atomic id claim. After the insert, `files` is checked again. If a file became visible in that window, the new pending session is dropped (best effort), chunks of the visible file are never deleted, and the call throws `GRIDFS_FILE_EXISTS`. Chunk bytes must follow the session size: - Every chunk except the last has length `chunkSizeBytes`. - The last chunk has length `length % chunkSizeBytes`, or `chunkSizeBytes` when the length divides evenly. -- A payload that does not split into those sizes throws `INVALID_ARGUMENT`. The whole payload is checked before any chunk is inserted. +- A payload that does not split into those sizes throws `INVALID_ARGUMENT`. For a `Buffer` or `Uint8Array`, the whole payload is planned and checked before any chunk is inserted. For a `Readable`, complete chunks are written as they arrive; a trailing partial or oversize remainder is rejected during consumption, after any complete prefix chunks have been stored. Retry from the reported `nextChunk`. Peak memory is the residual (shorter than the next expected chunk) plus the current incoming stream buffer, not the full payload. Use a modest `highWaterMark` for large files. `appendChunks` is addressed by `fromChunk`: @@ -94,7 +94,9 @@ Chunk bytes must follow the session size: `finalizeResumableUpload` requires a contiguous prefix, a total length equal to the session length, and a sha256 equal to the session digest. It then inserts the files document, sets `metadata.sha256`, and deletes the session. Missing chunk indexes throw `GRIDFS_UPLOAD_INCOMPLETE` with `details[0].missingChunks`. A bad digest or a chunk of the wrong size throws `GRIDFS_INTEGRITY_MISMATCH`. The files document is not inserted in those cases. -Retrying `finalizeResumableUpload` is idempotent while the session still exists: if a file with the same id is already visible and its `metadata.sha256` and length match the session, the call returns that file and deletes the session. A file with a different sha256 or length still throws `GRIDFS_FILE_EXISTS`. Once the session is gone (a finalize that fully succeeded, an abort, or the TTL), there is no digest to compare, so a retry throws `GRIDFS_FILE_NOT_FOUND`; use `getFile(uploadId)` to check the result. +Retrying `finalizeResumableUpload` is idempotent while the session still exists: if a file with the same id is already visible, the library re-hashes the actual stored chunks and returns the file only when that digest and the file length match the session. A file with a different sha256 or length still throws `GRIDFS_FILE_EXISTS`. Once the session is gone (a finalize that fully succeeded, an abort, or the TTL), there is no digest to compare, so a retry throws `GRIDFS_FILE_NOT_FOUND`. Callers must persist `uploadId`, `length`, and `sha256` themselves. After a vanished session, use `getFile(uploadId)` to see whether a file is visible, then re-hash the **actual** bytes (for example `download(uploadId)` or a walk of `.chunks`) and compare them to the persisted length and digest. Never treat `metadata.sha256` alone as proof: `uploadStream()` and a raw `metadata.sha256` can store an unverified claim. This package does not add a separate public verification API. + +A `finalizeResumableUpload` that fails with `GRIDFS_FILE_EXISTS` or `GRIDFS_INTEGRITY_MISMATCH` after it has claimed `finalizing` rolls the session back to `pending` so `abortResumableUpload` (and then a delete of a conflicting visible file) can proceed through the public API. Ambiguous driver errors during the files insert still leave `finalizing` for explicit recovery. `abortResumableUpload` atomically claims a pending session before deleting chunks. It fails with `GRIDFS_UPLOAD_BUSY` while append or finalize owns the session. It deletes the session and, when no files document is visible, that upload's chunks. A missing or already aborted session resolves, and any chunks left under that id without a visible file are still deleted. Aborting an id that already has a visible file does not delete that file's chunks. diff --git a/docs/en/support-matrix.md b/docs/en/support-matrix.md index 7c1a944..e3f1d69 100644 --- a/docs/en/support-matrix.md +++ b/docs/en/support-matrix.md @@ -18,7 +18,7 @@ | MongoDB Driver | 6.x / 7.x | `npm run test:server-matrix` | | MongoDB Server | 7.x / 8.x | strict `mongodb-memory-server` 7.0.37 / 8.0.26 single + replica set matrix | -GridFS is a client convention on `.files` and `.chunks`. Its integration tests run on `mongodb-memory-server` 7.0.37. That does not add MongoDB 4.x, or any server outside 7.x / 8.x, to this matrix. +GridFS is a client convention on `.files` and `.chunks`. Its integration tests run on `mongodb-memory-server` 7.0.37 and 8.0.26 in the current GridFS safety / release-gate jobs. That does not add MongoDB 4.x, or any server outside 7.x / 8.x, to this matrix. Search and Vector Search against Atlas, atlas-local, or mongot remain UNVERIFIED in this checkout; those deployments are not implied by the memory-server matrix. ## Default verification method diff --git a/docs/zh/gridfs-recovery.md b/docs/zh/gridfs-recovery.md index 3ef801a..1e0ada4 100644 --- a/docs/zh/gridfs-recovery.md +++ b/docs/zh/gridfs-recovery.md @@ -8,6 +8,16 @@ 任何强制恢复必须先从集群所有节点停止并排空旧版和新版 GridFS 写入进程、队列消费者、定时任务与自动重启,确认没有仍可恢复的旧写入。恢复脚本无法在 MongoDB 层直接阻止旧进程已发出的 chunks 写入;只有外部进程停机隔离后才安全。不得混用忽略 ownership 的旧版与新版本。 +dry-run 可以使用领取所有权之前的可见性快照;**apply 必须在独占 claim 之后重新读取 `files`**。若可见性已经变化,或在需要可见文件时 `verifyVisibleFile` 无法确认,脚本失败并保留 `recovering` 所有权。 + +排查未知 ID 的残留 chunks 时,使用只读巡检脚本 `scripts/maintenance/gridfs-inspect-orphans.cjs`。它按已有 `{ files_id, n }` 索引分页扫描 `.chunks`,在分类时重新核对 `files` 和 `uploads`,报告 `visible_file`、`owned` 或 `candidate_orphan`。没有 apply 模式,不会删除,也不会按文档年龄删除。它不是公开的 `GridFSBucket` API。 + +```sh +export MONSQLIZE_RECOVERY_MONGODB_URI='<从密钥管理系统读取的 MongoDB URI>' +node scripts/maintenance/gridfs-inspect-orphans.cjs --db=database --bucket=fs --page-size=200 +``` + + ## 上线和回滚步骤 1. 冻结所有 GridFS 写入,禁止新请求和自动重启,核查所有旧版 Writer 已排空;保留相关运行实例、进程、队列、版本和时间证据。 diff --git a/docs/zh/gridfs.md b/docs/zh/gridfs.md index e08df52..50d11b2 100644 --- a/docs/zh/gridfs.md +++ b/docs/zh/gridfs.md @@ -46,7 +46,7 @@ const modelBucket = model.gridfs(bucketName?: string, opts?: { `upload` 的 `ifExists` 默认为 `'error'`。 - 提供 `sha256` 时,冲突键是 `metadata.sha256`。摘要必须是 64 位小写十六进制。`Buffer` 与 `Uint8Array` 会在任何 GridFS 写入之前完成哈希。不一致时抛出 `GRIDFS_INTEGRITY_MISMATCH`,并且不会留下可见文件。 -- 带 `sha256` 的 `Readable` 会先完整缓冲再哈希,然后才写入。大文件应改用可恢复上传。 +- 带 `sha256` 的 `Readable` 会先完整缓冲再哈希,然后才写入。这是为了遵守“校验失败不会留下可见文件”的契约。不带 `sha256` 的 `upload()` 可以边读边写,但整段 payload 仍是一次提交。大文件应使用可恢复上传,用 `appendChunks` 配合 `highWaterMark` 较小的 `Readable`,不要传入数 GB 的 `Buffer`,也不要走会全量缓冲的 `upload({ sha256 })`。 - 带 `sha256` 的 `skip` 返回该摘要最新的可见文件。不带 `sha256` 的 `skip` 返回同名最新可见文件。`duplicate` 总是再创建一份。 - 通过 `metadata` 对象传入的 `metadata.sha256` 是调用方数据,不会被验证。`upload()` 如需校验字节,必须使用顶层 `sha256` 选项。 - `uploadStream` 的顶层 `sha256` 只写入 metadata,不校验字节,也不接受 `ifExists`。 @@ -75,13 +75,13 @@ const info = await bucket.finalizeResumableUpload(session.uploadId); await bucket.abortResumableUpload(session.uploadId); ``` -`beginResumableUpload` 必须提供 `length` 和 `sha256`。`files` 或 `uploads` 中已经存在该 id 时抛出 `GRIDFS_FILE_EXISTS`。 +`beginResumableUpload` 必须提供 `length` 和 `sha256`。`files` 或 `uploads` 中已经存在该 id 时抛出 `GRIDFS_FILE_EXISTS`。预检查不是锁,会话插入才是原子占位。插入之后会再次检查 `files`。若在这个窗口里文件已经可见,会尽量删除刚插入的 pending 会话,**绝不会**删除可见文件的 chunks,并抛出 `GRIDFS_FILE_EXISTS`。 分片长度必须符合会话: - 除最后一片外,每片长度都是 `chunkSizeBytes`。 - 最后一片长度是 `length % chunkSizeBytes`;能够整除时仍是 `chunkSizeBytes`。 -- 无法按这些长度切开的 payload 抛出 `INVALID_ARGUMENT`。整段 payload 会在插入任何分片之前完成检查。 +- 无法按这些长度切开的 payload 抛出 `INVALID_ARGUMENT`。对 `Buffer` / `Uint8Array`,整段 payload 会在插入任何分片之前完成规划与检查。对 `Readable`,完整分片会随读随写;末尾不足或超出的残余会在消费过程中被拒绝,此前已经写入的完整前缀分片会保留,调用方应从返回的 `nextChunk` 重试。峰值内存是“下一片所需长度以内的残余 + 当前流入的 stream 缓冲”,不会随整个 payload 线性增长。大文件应使用较小的 `highWaterMark`。 `appendChunks` 以 `fromChunk` 定位: @@ -94,7 +94,9 @@ await bucket.abortResumableUpload(session.uploadId); `finalizeResumableUpload` 要求分片从 0 连续、总长度等于会话长度,且 sha256 等于会话摘要。然后插入 files 文档、写入 `metadata.sha256`,并删除会话。缺少的分片序号抛出 `GRIDFS_UPLOAD_INCOMPLETE`,`details[0].missingChunks` 列出缺口。摘要不符或分片长度不对抛出 `GRIDFS_INTEGRITY_MISMATCH`。这些失败都不会插入 files 文档。 -会话仍在时,重试 `finalizeResumableUpload` 是幂等的:若同 id 的文件已可见,且其 `metadata.sha256` 与长度都和会话一致,调用直接返回该文件并删除会话。sha256 或长度不同的文件仍抛 `GRIDFS_FILE_EXISTS`。会话已不存在时(上一次 finalize 已完整成功、已 abort 或 TTL 过期),没有可比对的摘要,重试抛 `GRIDFS_FILE_NOT_FOUND`,可用 `getFile(uploadId)` 确认结果。 +会话仍在时,重试 `finalizeResumableUpload` 是幂等的:若同 id 的文件已可见,库会重新哈希**实际存储的 chunks**,只有摘要和长度都与会话一致时才返回该文件并删除会话。sha256 或长度不同的文件仍抛 `GRIDFS_FILE_EXISTS`。会话已不存在时(上一次 finalize 已完整成功、已 abort 或 TTL 过期),没有可比对的摘要,重试抛 `GRIDFS_FILE_NOT_FOUND`。调用方必须自己持久化 `uploadId`、`length` 和 `sha256`。会话消失后,先用 `getFile(uploadId)` 看文件是否可见,再对**实际字节**重新哈希(例如 `download(uploadId)` 或遍历 `.chunks`),与自己保存的长度和摘要比对。不要只信任 `metadata.sha256`:`uploadStream()` 和调用方写入的 `metadata.sha256` 可以是未校验的声明。本包不为此再提供新的公开校验 API。 + +`finalizeResumableUpload` 在已经占有 `finalizing` 之后,若因 `GRIDFS_FILE_EXISTS` 或 `GRIDFS_INTEGRITY_MISMATCH` 失败,会把会话 CAS 回 `pending`,以便通过公开 API 执行 `abortResumableUpload`(然后再删除冲突的可见文件)。files 插入过程中含义不明的驱动错误仍会保留 `finalizing`,需显式恢复。 `abortResumableUpload` 先原子取得 pending 会话的所有权;append 或 finalize 仍在执行时返回 `GRIDFS_UPLOAD_BUSY`,绝不删除其 chunks。取得所有权后删除会话;如果还没有可见的 files 文档,同时删除该上传的 chunks。会话不存在或已经中止时直接成功,并且仍会删除该 id 下没有可见文件的残留 chunks。对已经可见的文件调用 abort 不会删除该文件的 chunks。 diff --git a/docs/zh/support-matrix.md b/docs/zh/support-matrix.md index f74886c..ccc1cca 100644 --- a/docs/zh/support-matrix.md +++ b/docs/zh/support-matrix.md @@ -18,7 +18,7 @@ | MongoDB Driver | 6.x / 7.x | `npm run test:server-matrix` | | MongoDB Server | 7.x / 8.x | `mongodb-memory-server` 7.0.37 / 8.0.26 single + replica set 严格矩阵 | -GridFS 是 `.files` 与 `.chunks` 上的客户端约定。它的集成测试跑在 `mongodb-memory-server` 7.0.37 上。这不会把 MongoDB 4.x,或 7.x / 8.x 以外的服务器,写进本矩阵。 +GridFS 是 `.files` 与 `.chunks` 上的客户端约定。它的集成测试跑在当前 GridFS safety / release-gate 任务中的 `mongodb-memory-server` 7.0.37 和 8.0.26 上。这不会把 MongoDB 4.x,或 7.x / 8.x 以外的服务器,写进本矩阵。本检出内针对 Atlas、atlas-local 或 mongot 的 Search / Vector Search 仍为 UNVERIFIED;memory-server 矩阵不能外推到这些部署。 ## 默认验证方式 diff --git a/scripts/maintenance/gridfs-inspect-orphans.cjs b/scripts/maintenance/gridfs-inspect-orphans.cjs new file mode 100644 index 0000000..8863a0f --- /dev/null +++ b/scripts/maintenance/gridfs-inspect-orphans.cjs @@ -0,0 +1,158 @@ +'use strict'; + +/** + * Operator-only, inspect-only reporter for GridFS chunk files_id groups. + * Walks .chunks via the existing { files_id, n } index in range pages. + * Each files_id is classified only after a unified re-check of files and uploads: + * - visible_file: a files document exists + * - owned: no files document, but an uploads owner/session exists + * - candidate_orphan: no files document and no uploads owner at re-check time + * + * This tool never deletes, never applies, and never uses document age as a + * deletion criterion. It is not part of the public GridFSBucket API. + * + * Usage: + * MONSQLIZE_RECOVERY_MONGODB_URI=... node scripts/maintenance/gridfs-inspect-orphans.cjs \ + * --db=my_database --bucket=fs [--page-size=200] + */ +const { MongoClient, ObjectId } = require('mongodb'); + +function fail(message) { + const error = new Error(message); + error.code = 'GRIDFS_INSPECT_PRECONDITION_FAILED'; + throw error; +} + +async function classifyFilesId(db, bucket, group) { + const files = db.collection(bucket + '.files'); + const uploads = db.collection(bucket + '.uploads'); + const file = await files.findOne({ _id: group.id }, { projection: { _id: 1 } }); + const owner = await uploads.findOne( + { _id: group.id }, + { projection: { _id: 1, kind: 1, status: 1 } }, + ); + let classification; + if (file) classification = 'visible_file'; + else if (owner) classification = 'owned'; + else classification = 'candidate_orphan'; + const finding = { + filesId: group.id instanceof ObjectId ? group.id.toHexString() : String(group.id), + chunkCount: group.count, + minN: group.minN, + maxN: group.maxN, + classification, + }; + if (owner && !file) { + finding.ownerKind = owner.kind ?? null; + finding.ownerStatus = owner.status ?? null; + } + return finding; +} + +async function inspectGridFSOrphans(input) { + const { db, bucket, pageSize = 200 } = input; + if (!db || !bucket || typeof bucket !== 'string') fail('Invalid database or bucket'); + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 10000) { + fail('pageSize must be an integer between 1 and 10000'); + } + if (input.apply === true) fail('This inspector has no apply mode and never deletes'); + + const chunks = db.collection(bucket + '.chunks'); + const findings = []; + let lastId = null; + let lastN = -1; + let current = null; + let pages = 0; + + const flush = async (group) => { + findings.push(await classifyFilesId(db, bucket, group)); + }; + + for (;;) { + const filter = lastId == null + ? {} + : { + $or: [ + { files_id: { $gt: lastId } }, + { files_id: lastId, n: { $gt: lastN } }, + ], + }; + const page = await chunks.find(filter) + .sort({ files_id: 1, n: 1 }) + .project({ files_id: 1, n: 1 }) + .limit(pageSize) + .toArray(); + pages += 1; + if (page.length === 0) break; + for (const doc of page) { + const filesId = doc.files_id; + if (!current || !sameId(current.id, filesId)) { + if (current) await flush(current); + current = { id: filesId, minN: doc.n, maxN: doc.n, count: 1 }; + } else { + current.count += 1; + if (doc.n < current.minN) current.minN = doc.n; + if (doc.n > current.maxN) current.maxN = doc.n; + } + lastId = filesId; + lastN = doc.n; + } + if (page.length < pageSize) break; + } + if (current) await flush(current); + + const countOf = (name) => findings.filter((row) => row.classification === name).length; + return { + bucket, + pageSize, + pages, + filesIdsScanned: findings.length, + visibleFiles: countOf('visible_file'), + owned: countOf('owned'), + candidateOrphans: countOf('candidate_orphan'), + findings, + }; +} + +function sameId(left, right) { + if (left === right) return true; + if (left && right && typeof left.equals === 'function') return left.equals(right); + return String(left) === String(right); +} + +function readArg(args, name) { + const prefix = '--' + name + '='; + const found = args.find((a) => a.startsWith(prefix)); + return found && found.slice(prefix.length); +} + +async function main() { + const args = process.argv.slice(2); + if (args.includes('--apply')) fail('This inspector has no apply mode and never deletes'); + const uri = process.env.MONSQLIZE_RECOVERY_MONGODB_URI || process.env.MONSQLIZE_MAINTENANCE_MONGODB_URI; + if (!uri) fail('MONSQLIZE_RECOVERY_MONGODB_URI is required (do not put credentials in argv)'); + const dbName = readArg(args, 'db'); + const bucket = readArg(args, 'bucket'); + if (!dbName || !bucket) fail('--db and --bucket are required'); + const pageSizeArg = readArg(args, 'page-size'); + const pageSize = pageSizeArg === undefined ? 200 : Number(pageSizeArg); + if (pageSizeArg !== undefined && (!Number.isInteger(pageSize) || pageSize < 1)) { + fail('--page-size must be a positive integer'); + } + const client = new MongoClient(uri); + await client.connect(); + try { + const report = await inspectGridFSOrphans({ db: client.db(dbName), bucket, pageSize }); + process.stdout.write(JSON.stringify(report, null, 2) + '\n'); + } finally { + await client.close(); + } +} + +module.exports = { inspectGridFSOrphans }; +if (require.main === module) { + main().catch((error) => { + process.stderr.write(String(error.code || 'INSPECT_ERROR') + ': ' + error.message + '\n'); + process.exitCode = 1; + }); +} diff --git a/scripts/maintenance/gridfs-recover.cjs b/scripts/maintenance/gridfs-recover.cjs index d80f669..aad5fda 100644 --- a/scripts/maintenance/gridfs-recover.cjs +++ b/scripts/maintenance/gridfs-recover.cjs @@ -243,6 +243,13 @@ async function recoverGridFSOwnership(input) { try { const chunks = db.collection(bucket + '.chunks'); const files = db.collection(bucket + '.files'); + // Dry-run may use the pre-claim snapshot. Apply must re-read visibility + // after the exclusive claim and fail closed if it changed. + const visibleAfter = await files.findOne({ _id: id }, { projection: { _id: 1 } }); + const visibleNow = !!visibleAfter; + if (visibleNow !== !!visible) { + fail('File visibility changed after recovery claim; preserve recovering ownership'); + } if (sourceStatus === 'activeAppend') { const nextChunk = await contiguousChunkPrefix(db, bucket, id, doc); const changed = await uploads.updateOne(held, { @@ -251,7 +258,7 @@ async function recoverGridFSOwnership(input) { $unset: { activeAppend: '', recovery: '' }, }); if (changed.matchedCount !== 1) fail('Lost recovery claim while restoring append'); - } else if (sourceStatus === 'finalizing' && !visible) { + } else if (sourceStatus === 'finalizing' && !visibleNow) { const changed = await uploads.updateOne(held, { $set: { status: 'pending', updatedAt: new Date(), expiresAt: new Date(Date.now() + TTL_MS) }, @@ -263,16 +270,18 @@ async function recoverGridFSOwnership(input) { // unless this is an explicitly quiesced, retained "deleting" owner. if (sourceStatus === 'finalizing') { verification = await verifyVisibleFile(db, bucket, id, doc, doc); - } else if ((sourceStatus === 'uploading' || sourceStatus === 'renaming') && visible) { + if (!verification) fail('Visible file could not be verified after claim; preserve recovering ownership'); + } else if ((sourceStatus === 'uploading' || sourceStatus === 'renaming') && visibleNow) { verification = await verifyVisibleFile(db, bucket, id, null, doc); - } else if (sourceStatus === 'renaming' && !visible) { + if (!verification) fail('Visible file could not be verified after claim; preserve recovering ownership'); + } else if (sourceStatus === 'renaming' && !visibleNow) { fail('Renaming owner has no visible file; preserve ownership for manual inspection'); } if (sourceStatus === 'deleting') { await files.deleteOne({ _id: id }); await chunks.deleteMany({ files_id: id }); } else if ((sourceStatus === 'uploading' || sourceStatus === 'cleaning' || - sourceStatus === 'aborting') && !visible) { + sourceStatus === 'aborting') && !visibleNow) { await chunks.deleteMany({ files_id: id }); } const removed = await uploads.deleteOne(held); diff --git a/scripts/validation/run-memory-server-matrix.cjs b/scripts/validation/run-memory-server-matrix.cjs index f6a7071..09396e4 100644 --- a/scripts/validation/run-memory-server-matrix.cjs +++ b/scripts/validation/run-memory-server-matrix.cjs @@ -49,6 +49,8 @@ const integrationSourceSuites = [ 'test/integration/gridfs/gridfs-scan-regressions.test.js', 'test/integration/gridfs/gridfs-ownership-regressions.test.js', 'test/integration/gridfs/gridfs-stale-recovery.test.js', + 'test/integration/gridfs/gridfs-remaining-issues.test.js', + 'test/integration/gridfs/gridfs-inspect-orphans.test.js', ]; const integrationSuites = integrationSourceSuites.map((suite) => path.join(testDistRoot, suite)); const compatibilitySuites = [ diff --git a/src/capabilities/gridfs/operations.ts b/src/capabilities/gridfs/operations.ts index f425cd8..efc375a 100644 --- a/src/capabilities/gridfs/operations.ts +++ b/src/capabilities/gridfs/operations.ts @@ -291,9 +291,19 @@ export async function renameFile(ctx: GridFSContext, id: ObjectId, filename: str } catch (error) { throw mapDriverError(error, 'rename', ctx, { id, filename }); } - } finally { - // Rename never removes chunks and is safe to unlock after driver settles. - await releaseOwner(ctx, owner, 'rename'); + // A successful rename is committed even if removing its reservation fails. + try { + await releaseOwner(ctx, owner, 'rename'); + } catch (error) { + ctx.logger?.warn?.('[gridfs] rename committed but reservation cleanup failed', { bucket: ctx.bucketName, id: String(id), error }); + } + } catch (error) { + try { + await releaseOwner(ctx, owner, 'rename'); + } catch (releaseError) { + ctx.logger?.warn?.('[gridfs] rename reservation retained after cleanup failure', { bucket: ctx.bucketName, id: String(id), error: releaseError }); + } + throw error; } } diff --git a/src/capabilities/gridfs/resumable.ts b/src/capabilities/gridfs/resumable.ts index bb3245a..dc0f885 100644 --- a/src/capabilities/gridfs/resumable.ts +++ b/src/capabilities/gridfs/resumable.ts @@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'; import type { Readable } from 'node:stream'; import { ObjectId } from 'mongodb'; import type { AppendChunksResult, BeginResumableUploadOptions, BeginResumableUploadResult, GridFSContext, GridFSFileInfo, GridFSUploadSession } from './types'; -import { fileExists, fileNotFound, integrityMismatch, invalidArgument, isDuplicateKey, mapDriverError, resumeMismatch, uploadBusy, uploadIncomplete } from './errors'; +import { fileExists, fileNotFound, integrityMismatch, invalidArgument, isDuplicateKey, isGridFSDomainError, mapDriverError, resumeMismatch, uploadBusy, uploadIncomplete } from './errors'; import { cleanupOrphanById } from './ownership'; import { binaryToBuffer, chunksCollectionName, filesCollectionName, sameBytes, toFileInfo, uploadsCollectionName } from './normalize'; import { @@ -20,7 +20,7 @@ import { toNodeBuffer, withContentType, } from './validate'; -import { readReadable } from './bytes'; +import { consumeReadableChunks } from './stream-chunks'; export async function beginResumableUpload(ctx: GridFSContext, filename: string, opts: BeginResumableUploadOptions): Promise { assertFilename(filename, 'beginResumableUpload'); @@ -66,9 +66,18 @@ export async function beginResumableUpload(ctx: GridFSContext, filename: string, } catch (error) { throw mapDriverError(error, 'beginResumableUpload', ctx, { uploadId, filename }); } - // Chunks can outlive an expired session (the TTL only removes the session document). This call now owns - // the id and no file is visible, so leftovers would only break the new upload; remove them. + // The insert is the atomic id claim. A concurrent upload can still make a + // files document visible after the prechecks and even after this insert. + // Re-check files and fail closed: never delete chunks of a visible file. try { + if (await files.findOne({ _id: uploadId }, { projection: { _id: 1 } })) { + try { + await uploads.deleteOne({ _id: uploadId, kind: 'resumable', status: 'pending' }); + } catch { /* Best-effort session cleanup; chunks of the visible file stay. */ } + throw fileExists('beginResumableUpload', ctx, { id: uploadId, filename, sha256: opts.sha256 }); + } + // Chunks can outlive an expired session (the TTL only removes the session document). + // This call now owns the id and no file is visible, so leftovers would only break the new upload. await ctx.db.collection(chunksCollectionName(ctx.bucketName)).deleteMany({ files_id: uploadId }); } catch (error) { throw mapDriverError(error, 'beginResumableUpload', ctx, { uploadId, filename }); @@ -84,8 +93,15 @@ export async function appendChunks( ): Promise { assertObjectId(uploadId, 'appendChunks', 'uploadId'); assertNonNegativeInteger(fromChunk, 'appendChunks', 'fromChunk'); - const payload = await payloadBytes(data); - if (payload.length === 0) throw badChunkSize(); + const isStream = isReadableStream(data); + if (!isStream && !Buffer.isBuffer(data) && !(data instanceof Uint8Array)) { + throw invalidArgument('appendChunks', 'data', 'data must be a Buffer, Uint8Array, or Readable'); + } + let payload: Buffer | undefined; + if (!isStream) { + payload = toNodeBuffer(data as Buffer | Uint8Array); + if (payload.length === 0) throw badChunkSize(); + } const uploads = ctx.db.collection(uploadsCollectionName(ctx.bucketName)); const token = randomUUID(); // A durable single-append claim excludes finalize, abort, and concurrent @@ -111,14 +127,34 @@ export async function appendChunks( if (fromChunk > nextChunk) { throw resumeMismatch('appendChunks', ctx.bucketName, { uploadId, fromChunk, expectedNextChunk: nextChunk }); } - const slices = planChunkSlices(payload, fromChunk, session.length, session.chunkSizeBytes); - const lastIndex = slices[slices.length - 1].index; - if (lastIndex >= nextChunk && fromChunk !== nextChunk) { - throw resumeMismatch('appendChunks', ctx.bucketName, { uploadId, fromChunk, expectedNextChunk: nextChunk }); - } - for (const slice of slices) { - if (slice.index < nextChunk) await assertStoredChunk(ctx, uploadId, slice.index, slice.bytes, nextChunk); - else await writeChunk(ctx, uploadId, slice.index, slice.bytes, nextChunk); + const writeSlice = async (slice: { index: number; bytes: Buffer }): Promise => { + if (slice.index < nextChunk) { + await assertStoredChunk(ctx, uploadId, slice.index, slice.bytes, nextChunk); + return; + } + if (fromChunk !== nextChunk) { + throw resumeMismatch('appendChunks', ctx.bucketName, { uploadId, fromChunk, expectedNextChunk: nextChunk }); + } + await writeChunk(ctx, uploadId, slice.index, slice.bytes, nextChunk); + }; + let bytesAccepted: number; + if (isStream) { + const streamed = await consumeReadableChunks( + data as Readable, + fromChunk, + session.length, + session.chunkSizeBytes, + writeSlice, + ); + bytesAccepted = streamed.bytesAccepted; + } else { + const slices = planChunkSlices(payload as Buffer, fromChunk, session.length, session.chunkSizeBytes); + const lastIndex = slices[slices.length - 1].index; + if (lastIndex >= nextChunk && fromChunk !== nextChunk) { + throw resumeMismatch('appendChunks', ctx.bucketName, { uploadId, fromChunk, expectedNextChunk: nextChunk }); + } + for (const slice of slices) await writeSlice(slice); + bytesAccepted = (payload as Buffer).length; } const advanced = await contiguousNext(ctx, uploadId, nextChunk); const completed = await uploads.updateOne( @@ -130,7 +166,7 @@ export async function appendChunks( }, ); if (!completed.matchedCount) throw uploadBusy('appendChunks', ctx, uploadId); - return { nextChunk: advanced, bytesAccepted: payload.length }; + return { nextChunk: advanced, bytesAccepted }; } catch (error) { // The worker has stopped writing. Releasing only its own token allows // the next append to reconcile partial chunks idempotently. If release @@ -169,8 +205,24 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje // fail its pending -> aborting CAS instead of deleting validated chunks. session = await requireSession(ctx, uploadId, 'finalizeResumableUpload'); const files = ctx.db.collection(filesCollectionName(ctx.bucketName)); - const committed = await committedFile(ctx, uploadId, session); - if (committed) return committed; + + const restorePendingAfterClosedFailure = async (error: unknown): Promise => { + if (!isClosedFinalizeFailure(error)) return; + try { + await uploads.updateOne( + { _id: uploadId, status: 'finalizing' }, + { $set: { status: 'pending', updatedAt: new Date(), expiresAt: new Date(Date.now() + 604800 * 1000) } }, + ); + } catch { /* The retained finalizing state must be recovered explicitly. */ } + }; + + try { + const already = await committedFile(ctx, uploadId, session); + if (already) return already; + } catch (error) { + await restorePendingAfterClosedFailure(error); + throw error; + } try { const expected = chunkCount(session.length, session.chunkSizeBytes); @@ -237,12 +289,21 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje }); } catch (error) { if (isDuplicateKey(error)) { - const raced = await committedFile(ctx, uploadId, session); - if (raced) return raced; + try { + const raced = await committedFile(ctx, uploadId, session); + if (raced) return raced; + } catch (conflict) { + await restorePendingAfterClosedFailure(conflict); + throw conflict; + } + } + const mapped = mapDriverError(error, 'finalizeResumableUpload', ctx, { uploadId, filename: session.filename }); + if (isClosedFinalizeFailure(mapped)) { + await restorePendingAfterClosedFailure(mapped); } - // Commit outcome may be ambiguous; hold finalizing ownership rather than + // Ambiguous commit outcomes keep finalizing ownership rather than // allowing an abort or another writer to delete/reuse the same chunks. - throw mapDriverError(error, 'finalizeResumableUpload', ctx, { uploadId, filename: session.filename }); + throw mapped; } try { await uploads.deleteOne({ _id: uploadId, status: 'finalizing' }); @@ -346,10 +407,9 @@ async function removeInvisibleChunks(ctx: GridFSContext, uploadId: ObjectId, ope } } -async function payloadBytes(data: Buffer | Uint8Array | Readable): Promise { - if (Buffer.isBuffer(data) || data instanceof Uint8Array) return toNodeBuffer(data); - if (isReadableStream(data)) return readReadable(data); - throw invalidArgument('appendChunks', 'data', 'data must be a Buffer, Uint8Array, or Readable'); +function isClosedFinalizeFailure(error: unknown): boolean { + return isGridFSDomainError(error) + && (error.code === 'GRIDFS_FILE_EXISTS' || error.code === 'GRIDFS_INTEGRITY_MISMATCH'); } function badChunkSize() { diff --git a/src/capabilities/gridfs/stream-chunks.ts b/src/capabilities/gridfs/stream-chunks.ts new file mode 100644 index 0000000..f3e1ba8 --- /dev/null +++ b/src/capabilities/gridfs/stream-chunks.ts @@ -0,0 +1,139 @@ +import type { Readable } from 'node:stream'; +import { invalidArgument } from './errors'; +import { chunkCount, chunkLengthAt } from './validate'; + +export interface StreamedChunk { + index: number; + bytes: Buffer; +} + +function badChunkSize(): never { + throw invalidArgument('appendChunks', 'data', 'chunk payload must match the session chunk size'); +} + +/** + * Consume a Readable into GridFS-sized slices without buffering the whole payload. + * Retention is limited to a residual shorter than the next expected slice plus the + * current incoming `read()` buffer. The stream is not pulled again until the + * current complete slices have been awaited, so Node backpressure applies. + */ +export async function consumeReadableChunks( + source: Readable, + fromChunk: number, + length: number, + chunkSizeBytes: number, + onChunk: (slice: StreamedChunk) => Promise, + onBufferedBytes?: (bytes: number) => void, +): Promise<{ bytesAccepted: number }> { + const total = chunkCount(length, chunkSizeBytes); + let residual = Buffer.alloc(0); + let index = fromChunk; + let bytesAccepted = 0; + let ended = false; + let busy = false; + let settled = false; + let pending = false; + + const report = (incoming = 0): void => { + onBufferedBytes?.(residual.length + incoming); + }; + + const emit = async (slice: StreamedChunk): Promise => { + report(); + await onChunk(slice); + bytesAccepted += slice.bytes.length; + index += 1; + }; + + const takeFrom = async (incoming: Buffer): Promise => { + let offset = 0; + report(incoming.length); + while (index < total && offset < incoming.length) { + const want = chunkLengthAt(index, length, chunkSizeBytes); + const have = residual.length + (incoming.length - offset); + if (have < want) { + const rest = incoming.subarray(offset); + residual = residual.length === 0 + ? Buffer.from(rest) + : Buffer.concat([residual, rest]); + report(); + return; + } + let bytes: Buffer; + if (residual.length === 0) { + bytes = Buffer.from(incoming.subarray(offset, offset + want)); + offset += want; + } else { + const need = want - residual.length; + bytes = Buffer.concat([residual, incoming.subarray(offset, offset + need)]); + residual = Buffer.alloc(0); + offset += need; + } + await emit({ index, bytes }); + } + if (index >= total && offset < incoming.length) badChunkSize(); + report(); + }; + + await new Promise((resolve, reject) => { + const finish = (error?: unknown): void => { + if (settled) return; + settled = true; + if (error) reject(error); + else resolve(); + }; + + const pump = async (): Promise => { + if (settled || busy) return; + busy = true; + try { + let incoming: Buffer | string | null; + while ((incoming = source.read() as Buffer | string | null) !== null) { + await takeFrom(Buffer.isBuffer(incoming) ? incoming : Buffer.from(incoming)); + } + if (ended || source.readableEnded) { + ended = true; + if (residual.length > 0) badChunkSize(); + } + } finally { + busy = false; + } + }; + + const run = (): void => { + if (settled) return; + if (busy) { + pending = true; + return; + } + pump().then(() => { + if (settled) return; + if (pending) { + pending = false; + run(); + return; + } + // Only the pump that observed a clean end may resolve. A stale + // success handler must not hide a later residual/size failure. + if ((ended || source.readableEnded) && residual.length === 0) finish(); + }, (error: unknown) => finish(error)); + }; + + source.on('readable', run); + source.on('end', () => { + ended = true; + run(); + }); + source.on('error', (error) => finish(error)); + run(); + if (!ended && source.readableEnded) { + ended = true; + run(); + } + }).finally(() => { + if (!source.destroyed && !source.readableEnded) source.destroy(); + }); + + if (bytesAccepted === 0) badChunkSize(); + return { bytesAccepted }; +} diff --git a/test/integration/gridfs/gridfs-inspect-orphans.test.ts b/test/integration/gridfs/gridfs-inspect-orphans.test.ts new file mode 100644 index 0000000..d7ee58f --- /dev/null +++ b/test/integration/gridfs/gridfs-inspect-orphans.test.ts @@ -0,0 +1,120 @@ +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Collection, MongoClient, ObjectId, type Db } from 'mongodb'; +import { createMemoryServerBootstrap } from '../../bootstrap/memory-server'; + +const { inspectGridFSOrphans } = require(process.cwd() + '/scripts/maintenance/gridfs-inspect-orphans.cjs'); + +describe('GridFS inspect-only orphan reporter', () => { + const bootstrap = createMemoryServerBootstrap(); + let raw: MongoClient; + let uri: string; + let counter = 0; + + before(async () => { + uri = (await bootstrap.setup()).uri; + raw = new MongoClient(uri); + await raw.connect(); + }); + after(async () => { + await raw.close(); + await bootstrap.teardown(); + }); + + async function withDb(fn: (db: Db) => Promise): Promise { + const db = raw.db('gridfs_inspect_' + (++counter)); + try { + await db.collection('b.chunks').createIndex({ files_id: 1, n: 1 }, { unique: true }); + await fn(db); + } finally { + await db.dropDatabase(); + } + } + + it('classifies visible, owned, and candidate orphan files_id groups without deleting', async () => { + await withDb(async (db) => { + const visible = new ObjectId(); + const owned = new ObjectId(); + const orphan = new ObjectId(); + const ancient = new Date('2018-01-01T00:00:00.000Z'); + await db.collection('b.files').insertOne({ + _id: visible, filename: 'ok.bin', length: 4, chunkSize: 4, uploadDate: new Date(), + }); + await db.collection('b.chunks').insertMany([ + { files_id: visible, n: 0, data: Buffer.from('ABCD') }, + { files_id: owned, n: 0, data: Buffer.from('PEND') }, + { files_id: orphan, n: 0, data: Buffer.from('ORPH') }, + { files_id: orphan, n: 1, data: Buffer.from('ANED') }, + ]); + await db.collection('b.uploads').insertOne({ + _id: owned, kind: 'resumable', status: 'pending', createdAt: ancient, updatedAt: ancient, + }); + + const originalDeleteMany = Collection.prototype.deleteMany; + let deletes = 0; + (Collection.prototype as any).deleteMany = async function (...args: unknown[]) { + deletes += 1; + return (originalDeleteMany as any).apply(this, args); + }; + try { + await assert.rejects( + () => inspectGridFSOrphans({ db, bucket: 'b', apply: true }), + /no apply mode/, + ); + const report = await inspectGridFSOrphans({ db, bucket: 'b', pageSize: 2 }); + assert.equal(report.filesIdsScanned, 3); + assert.equal(report.visibleFiles, 1); + assert.equal(report.owned, 1); + assert.equal(report.candidateOrphans, 1); + assert.ok(report.pages >= 2); + const byId = Object.fromEntries(report.findings.map((row: { filesId: string }) => [row.filesId, row])); + assert.equal(byId[visible.toHexString()].classification, 'visible_file'); + assert.equal(byId[owned.toHexString()].classification, 'owned'); + assert.equal(byId[owned.toHexString()].ownerStatus, 'pending'); + assert.equal(byId[orphan.toHexString()].classification, 'candidate_orphan'); + assert.equal(byId[orphan.toHexString()].chunkCount, 2); + assert.equal(deletes, 0); + assert.equal(await db.collection('b.chunks').countDocuments(), 4); + assert.equal(await db.collection('b.uploads').countDocuments(), 1); + } finally { + Collection.prototype.deleteMany = originalDeleteMany; + } + }); + }); + + it('re-checks ownership after pagination and does not classify a late-visible id as an orphan', async () => { + await withDb(async (db) => { + const first = new ObjectId(); + const second = new ObjectId(); + const third = new ObjectId(); + // ObjectIds are time-ordered; insert so walk order is first, second, third. + const ids = [first, second, third].sort((a, b) => a.toString().localeCompare(b.toString())); + await db.collection('b.chunks').insertMany(ids.map((id) => ({ + files_id: id, n: 0, data: Buffer.from('DATA'), + }))); + + const originalFindOne = Collection.prototype.findOne; + (Collection.prototype as any).findOne = async function (...args: unknown[]) { + // After the first files_id is classified, make the last id visible before its re-check. + if (this.collectionName === 'b.files') { + const filter = args[0] as { _id?: ObjectId }; + if (filter?._id && filter._id.equals(ids[0])) { + await db.collection('b.files').insertOne({ + _id: ids[2], filename: 'late.bin', length: 4, chunkSize: 4, uploadDate: new Date(), + }); + } + } + return (originalFindOne as any).apply(this, args); + }; + try { + const report = await inspectGridFSOrphans({ db, bucket: 'b', pageSize: 1 }); + const byId = Object.fromEntries(report.findings.map((row: { filesId: string }) => [row.filesId, row])); + assert.equal(byId[ids[0].toHexString()].classification, 'candidate_orphan'); + assert.equal(byId[ids[2].toHexString()].classification, 'visible_file'); + assert.equal(await db.collection('b.chunks').countDocuments(), 3); + } finally { + Collection.prototype.findOne = originalFindOne; + } + }); + }); +}); diff --git a/test/integration/gridfs/gridfs-remaining-issues.test.ts b/test/integration/gridfs/gridfs-remaining-issues.test.ts new file mode 100644 index 0000000..0f7fe2a --- /dev/null +++ b/test/integration/gridfs/gridfs-remaining-issues.test.ts @@ -0,0 +1,138 @@ +import { createHash } from 'node:crypto'; +import { Readable } from 'node:stream'; +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Collection, MongoClient, ObjectId, type Db } from 'mongodb'; +import { createMemoryServerBootstrap } from '../../bootstrap/memory-server'; + +const MonSQLize = require('../../../dist/cjs/index.cjs'); + +function sha(data: Buffer): string { + return createHash('sha256').update(data).digest('hex'); +} + +describe('GridFS remaining-issue regressions (begin race, finalize rollback, streaming append)', () => { + const bootstrap = createMemoryServerBootstrap(); + let uri = ''; + let raw: MongoClient; + + before(async () => { + uri = (await bootstrap.setup()).uri; + raw = new MongoClient(uri); + await raw.connect(); + }); + + after(async () => { + await raw.close(); + await bootstrap.teardown(); + MonSQLize.Model._clear(); + }); + + async function withRuntime(databaseName: string, fn: (runtime: any, db: Db) => Promise): Promise { + const runtime = new MonSQLize({ type: 'mongodb', databaseName, config: { uri } }); + await runtime.connect(); + try { + await fn(runtime, raw.db(databaseName)); + } finally { + await runtime.close(); + await raw.db(databaseName).dropDatabase(); + } + } + + it('beginResumableUpload does not delete chunks of a file that became visible after precheck', { timeout: 30_000 }, async () => { + await withRuntime('gridfs_begin_precheck_race', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const id = new ObjectId(); + const payload = Buffer.from('ABCD'); + const original = Collection.prototype.insertOne; + (Collection.prototype as any).insertOne = async function (this: Collection, ...args: unknown[]) { + const document = args[0] as Record; + if (this.collectionName === 'b.uploads' && document.kind === 'resumable' && (document._id as ObjectId).equals(id)) { + await original.call(db.collection('b.files'), { + _id: id, + filename: 'visible.bin', + length: 4, + chunkSize: 4, + uploadDate: new Date(), + metadata: { sha256: sha(payload) }, + }); + await original.call(db.collection('b.chunks'), { files_id: id, n: 0, data: payload }); + } + return (original as any).apply(this, args); + }; + try { + await assert.rejects( + bucket.beginResumableUpload('raced.bin', { id, length: 4, sha256: sha(payload) }), + (error: unknown) => (error as { code?: string }).code === 'GRIDFS_FILE_EXISTS', + ); + assert.equal(await db.collection('b.chunks').countDocuments({ files_id: id }), 1); + assert.deepEqual(await bucket.download(id), payload); + assert.equal(await db.collection('b.uploads').countDocuments({ _id: id }), 0); + } finally { + Collection.prototype.insertOne = original; + } + }); + }); + + it('finalize FILE_EXISTS rolls back to pending so public abort works and visible chunks stay', { timeout: 30_000 }, async () => { + await withRuntime('gridfs_finalize_rollback', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const payload = Buffer.from('GOOD'); + const session = await bucket.beginResumableUpload('conflict.bin', { + length: 4, sha256: sha(payload), + }); + await bucket.appendChunks(session.uploadId, 0, payload); + await db.collection('b.files').insertOne({ + _id: session.uploadId, + filename: 'other.bin', + length: 4, + chunkSize: 4, + uploadDate: new Date(), + metadata: { sha256: 'a'.repeat(64) }, + }); + await assert.rejects( + bucket.finalizeResumableUpload(session.uploadId), + (error: unknown) => (error as { code?: string }).code === 'GRIDFS_FILE_EXISTS', + ); + assert.equal((await db.collection('b.uploads').findOne({ _id: session.uploadId }))?.status, 'pending'); + await bucket.abortResumableUpload(session.uploadId); + assert.equal(await db.collection('b.uploads').countDocuments({ _id: session.uploadId }), 0); + assert.ok(await db.collection('b.files').findOne({ _id: session.uploadId })); + assert.equal(await db.collection('b.chunks').countDocuments({ files_id: session.uploadId }), 1); + }); + }); + + it('streams a Readable append with ownership, idempotent replay, and recoverable partial failure', { timeout: 30_000 }, async () => { + await withRuntime('gridfs_stream_append', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const payload = Buffer.from('aaaabbbbcccc'); + const otherId = new ObjectId(); + await db.collection('b.chunks').insertOne({ files_id: otherId, n: 0, data: Buffer.from('KEEP') }); + + const session = await bucket.beginResumableUpload('stream.bin', { + length: payload.length, sha256: sha(payload), + }); + const first = await bucket.appendChunks( + session.uploadId, + 0, + Readable.from([payload.subarray(0, 4), payload.subarray(4, 8)]), + ); + assert.equal(first.nextChunk, 2); + await assert.rejects( + bucket.appendChunks(session.uploadId, 2, Readable.from([Buffer.from('cc')])), + (error: unknown) => (error as { code?: string }).code === 'INVALID_ARGUMENT', + ); + assert.deepEqual( + (await db.collection('b.chunks').find({ files_id: session.uploadId }).sort({ n: 1 }).toArray()).map((chunk) => chunk.n), + [0, 1], + ); + const rest = await bucket.appendChunks(session.uploadId, 2, Readable.from([payload.subarray(8)])); + assert.equal(rest.nextChunk, 3); + const replay = await bucket.appendChunks(session.uploadId, 0, Readable.from([payload])); + assert.equal(replay.nextChunk, 3); + const info = await bucket.finalizeResumableUpload(session.uploadId); + assert.deepEqual(await bucket.download(info.id), payload); + assert.equal(await db.collection('b.chunks').countDocuments({ files_id: otherId }), 1); + }); + }); +}); diff --git a/test/integration/gridfs/gridfs-stale-recovery.test.ts b/test/integration/gridfs/gridfs-stale-recovery.test.ts index 0f95c7f..a0426ba 100644 --- a/test/integration/gridfs/gridfs-stale-recovery.test.ts +++ b/test/integration/gridfs/gridfs-stale-recovery.test.ts @@ -547,4 +547,70 @@ describe('GridFS operator recovery: deterministic post-crash state and chunk saf }); }); + + it('fails closed when visibility changes after the recovery claim', async () => { + await withRuntime(async (_, db) => { + const id = new ObjectId(); + const now = new Date(); + await db.collection('b.uploads').insertOne({ + _id: id, kind: 'direct', status: 'uploading', ownerToken: 'direct-race', + createdAt: now, updatedAt: now, + }); + await db.collection('b.chunks').insertOne({ files_id: id, n: 0, data: Buffer.from('ABCD') }); + const original = Collection.prototype.updateOne; + (Collection.prototype as any).updateOne = async function (this: Collection, ...args: unknown[]) { + const result = await (original as any).apply(this, args); + const update = args[1] as { $set?: { status?: string } }; + if (this.collectionName === 'b.uploads' && update?.$set?.status === 'recovering') { + await db.collection('b.files').insertOne({ + _id: id, filename: 'appeared.bin', length: 4, chunkSize: 4, uploadDate: new Date(), + }); + } + return result; + }; + try { + await assert.rejects(recover(db, id), /visibility changed after recovery claim/); + assert.equal((await db.collection('b.uploads').findOne({ _id: id }))?.status, 'recovering'); + assert.equal(await db.collection('b.chunks').countDocuments({ files_id: id }), 1); + assert.ok(await db.collection('b.files').findOne({ _id: id })); + } finally { + Collection.prototype.updateOne = original; + } + }); + }); + + it('fails closed when a required visible file cannot be verified after the claim', async () => { + await withRuntime(async (bucket, db) => { + const id = new ObjectId(); + const payload = Buffer.from('ABCD'); + await bucket.upload('final.bin', payload, { id, sha256: digest(payload) }); + // upload already released; implant a leftover finalizing session on the visible id + const now = new Date(); + await db.collection('b.uploads').insertOne({ + _id: id, kind: 'resumable', status: 'finalizing', filename: 'final.bin', + length: 4, sha256: digest(payload), chunkSizeBytes: 4, + createdAt: now, updatedAt: now, + }); + const original = Collection.prototype.findOne; + let verifiedLookups = 0; + (Collection.prototype as any).findOne = async function (this: Collection, ...args: unknown[]) { + const filter = args[0] as { _id?: ObjectId }; + const options = args[1] as { projection?: { _id?: number } } | undefined; + if (this.collectionName === 'b.files' && filter?._id?.equals?.(id) && !options?.projection) { + verifiedLookups += 1; + return null; + } + return (original as any).apply(this, args); + }; + try { + await assert.rejects(recover(db, id), /could not be verified after claim/); + assert.equal(verifiedLookups, 1); + assert.equal((await db.collection('b.uploads').findOne({ _id: id }))?.status, 'recovering'); + assert.ok(await db.collection('b.chunks').countDocuments({ files_id: id }) >= 1); + } finally { + Collection.prototype.findOne = original; + } + assert.ok(await db.collection('b.files').findOne({ _id: id })); + }); + }); }); diff --git a/test/run-tests.cjs b/test/run-tests.cjs index 0627adf..7714ead 100644 --- a/test/run-tests.cjs +++ b/test/run-tests.cjs @@ -54,6 +54,7 @@ const UNIT = [ 'test/unit/expression/operators.test.js', 'test/unit/errors/errors.test.js', 'test/unit/gridfs/gridfs.test.js', + 'test/unit/gridfs/stream-chunks.test.js', 'test/unit/search-indexes/search-indexes.test.js', 'test/unit/management/management.test.js', 'test/unit/writes/batch.test.js', @@ -139,6 +140,8 @@ const INTEGRATION = [ 'test/integration/gridfs/gridfs-scan-regressions.test.js', 'test/integration/gridfs/gridfs-ownership-regressions.test.js', 'test/integration/gridfs/gridfs-stale-recovery.test.js', + 'test/integration/gridfs/gridfs-remaining-issues.test.js', + 'test/integration/gridfs/gridfs-inspect-orphans.test.js', 'test/integration/mongodb/review-repair-regressions.test.js', 'test/integration/model/review-model-regressions.test.js', 'test/integration/slow-query-log/review-storage-regressions.test.js', diff --git a/test/unit/gridfs/gridfs.test.ts b/test/unit/gridfs/gridfs.test.ts index 2810bcf..d79a94c 100644 --- a/test/unit/gridfs/gridfs.test.ts +++ b/test/unit/gridfs/gridfs.test.ts @@ -204,6 +204,7 @@ function bucketFor(options: { driver?: boolean; instanceId?: string; poolName?: string; + logger?: { warn: (...args: unknown[]) => void }; } = {}) { const db = new MemoryDb(); const calls: string[] = []; @@ -267,6 +268,7 @@ function bucketFor(options: { source: options.source ?? 'db', policy: options.policy, driver: options.driver === false ? undefined : driver as never, + logger: options.logger, }); return { bucket, db, calls, bytes }; } @@ -639,4 +641,121 @@ describe('gridfs bucket', () => { return true; }); }); + + it('does not delete a visible file\'s chunks when beginResumableUpload races after precheck', async () => { + const { bucket, db } = bucketFor({ driver: false }); + const id = new ObjectId(); + const payload = Buffer.from('ABCD'); + const uploads = db.collection('fs.uploads'); + const original = uploads.insertOne.bind(uploads); + uploads.insertOne = async (doc: Doc) => { + if (doc.kind === 'resumable' && sameId(doc._id, id)) { + await db.collection('fs.files').insertOne({ + _id: id, filename: 'visible.bin', length: 4, chunkSize: 4, uploadDate: new Date(), + }); + await db.collection('fs.chunks').insertOne({ files_id: id, n: 0, data: payload }); + } + return original(doc); + }; + await assert.rejects( + () => bucket.beginResumableUpload('raced.bin', { id, length: 4, sha256: sha(payload), chunkSizeBytes: 4 }), + (error: unknown) => codeOf(error) === 'GRIDFS_FILE_EXISTS', + ); + assert.equal((await db.collection('fs.chunks').find({ files_id: id }).toArray()).length, 1); + assert.ok(await db.collection('fs.files').findOne({ _id: id })); + assert.equal(await db.collection('fs.uploads').findOne({ _id: id }), null); + }); + + it('rolls finalizing back to pending after FILE_EXISTS or INTEGRITY_MISMATCH so abort works', async () => { + const { bucket, db } = bucketFor({ driver: false }); + const payload = Buffer.from('abcd'); + const conflict = await bucket.beginResumableUpload('conflict.bin', { + length: 4, sha256: sha(payload), chunkSizeBytes: 4, + }); + await bucket.appendChunks(conflict.uploadId, 0, payload); + await db.collection('fs.files').insertOne({ + _id: conflict.uploadId, + filename: 'other.bin', + length: 4, + chunkSize: 4, + uploadDate: new Date(), + metadata: { sha256: 'a'.repeat(64) }, + }); + await assert.rejects( + () => bucket.finalizeResumableUpload(conflict.uploadId), + (error: unknown) => codeOf(error) === 'GRIDFS_FILE_EXISTS', + ); + assert.equal((await db.collection('fs.uploads').findOne({ _id: conflict.uploadId }))?.status, 'pending'); + await bucket.abortResumableUpload(conflict.uploadId); + assert.equal(await db.collection('fs.uploads').findOne({ _id: conflict.uploadId }), null); + assert.ok(await db.collection('fs.files').findOne({ _id: conflict.uploadId })); + assert.equal((await db.collection('fs.chunks').find({ files_id: conflict.uploadId }).toArray()).length, 1); + + const bad = await bucket.beginResumableUpload('bad.bin', { + length: 4, sha256: sha(Buffer.from('nope')), chunkSizeBytes: 4, + }); + await bucket.appendChunks(bad.uploadId, 0, payload); + await assert.rejects( + () => bucket.finalizeResumableUpload(bad.uploadId), + (error: unknown) => codeOf(error) === 'GRIDFS_INTEGRITY_MISMATCH', + ); + assert.equal((await db.collection('fs.uploads').findOne({ _id: bad.uploadId }))?.status, 'pending'); + await bucket.abortResumableUpload(bad.uploadId); + assert.equal((await db.collection('fs.chunks').find({ files_id: bad.uploadId }).toArray()).length, 0); + assert.equal(await db.collection('fs.uploads').findOne({ _id: bad.uploadId }), null); + }); + + it('returns a successful rename even when reservation release fails, and keeps the original error otherwise', async () => { + const warnings: string[] = []; + const { bucket, db } = bucketFor({ + logger: { warn: (message: unknown) => { warnings.push(String(message)); } }, + }); + const info = await bucket.upload('old.bin', Buffer.from('ok')); + const uploads = db.collection('fs.uploads'); + const original = uploads.deleteOne.bind(uploads); + uploads.deleteOne = async (filter: Doc) => { + if (filter.kind === 'rename') return { deletedCount: 0 }; + return original(filter); + }; + await bucket.rename(info.id, 'new.bin'); + assert.equal((await bucket.getFile(info.id))?.filename, 'new.bin'); + assert.ok(warnings.some((message) => message.includes('rename committed but reservation cleanup failed'))); + + warnings.length = 0; + uploads.deleteOne = async () => { + throw driverFailure('release failed', 91); + }; + const missing = new ObjectId(); + await assert.rejects( + () => bucket.rename(missing, 'gone.bin'), + (error: unknown) => codeOf(error) === 'GRIDFS_FILE_NOT_FOUND', + ); + assert.ok(warnings.some((message) => message.includes('rename reservation retained after cleanup failure'))); + assert.ok(await db.collection('fs.uploads').findOne({ _id: missing, kind: 'rename' })); + }); + + it('streams Readable appends, rejects a trailing partial after writing complete chunks, and remains abortable', async () => { + const { bucket, db } = bucketFor({ driver: false }); + const payload = Buffer.from('abcdefghijkl'); + const started = await bucket.beginResumableUpload('stream.bin', { + length: payload.length, sha256: sha(payload), chunkSizeBytes: 4, + }); + const first = await bucket.appendChunks(started.uploadId, 0, Readable.from([payload.subarray(0, 4), payload.subarray(4, 8)])); + assert.equal(first.nextChunk, 2); + await assert.rejects( + () => bucket.appendChunks(started.uploadId, 2, Readable.from([Buffer.from('ij')])), + (error: unknown) => codeOf(error) === 'INVALID_ARGUMENT', + ); + assert.deepEqual( + (await db.collection('fs.chunks').find({ files_id: started.uploadId }).sort({ n: 1 }).toArray()).map((chunk) => chunk.n), + [0, 1], + ); + assert.equal((await db.collection('fs.uploads').findOne({ _id: started.uploadId }))?.activeAppend, undefined); + const rest = await bucket.appendChunks(started.uploadId, 2, Readable.from([payload.subarray(8)])); + assert.equal(rest.nextChunk, 3); + const replay = await bucket.appendChunks(started.uploadId, 0, Readable.from([payload.subarray(0, 8)])); + assert.equal(replay.nextChunk, 3); + const info = await bucket.finalizeResumableUpload(started.uploadId); + assert.equal(info.length, payload.length); + }); }); diff --git a/test/unit/gridfs/stream-chunks.test.ts b/test/unit/gridfs/stream-chunks.test.ts new file mode 100644 index 0000000..e093f3f --- /dev/null +++ b/test/unit/gridfs/stream-chunks.test.ts @@ -0,0 +1,148 @@ +import { Readable } from 'node:stream'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { consumeReadableChunks } from '../../../src/capabilities/gridfs/stream-chunks'; + +function codeOf(error: unknown): string | undefined { + return error && typeof error === 'object' && 'code' in error ? String((error as { code: unknown }).code) : undefined; +} + +function delayed(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function readableFromBuffer(data: Buffer, highWaterMark: number): Readable { + let offset = 0; + return new Readable({ + highWaterMark, + read() { + if (offset >= data.length) { + this.push(null); + return; + } + const size = Math.min(highWaterMark, data.length - offset); + const slice = data.subarray(offset, offset + size); + offset += size; + this.push(slice); + }, + }); +} + +describe('consumeReadableChunks', () => { + it('keeps peak buffered memory from scaling with input size', async () => { + async function measure(size: number): Promise<{ peak: number; bytesAccepted: number }> { + const chunkSize = 1024; + const highWaterMark = 256; + const data = Buffer.alloc(size, 7); + let peak = 0; + const result = await consumeReadableChunks( + readableFromBuffer(data, highWaterMark), + 0, + size, + chunkSize, + async () => { await delayed(1); }, + (bytes) => { peak = Math.max(peak, bytes); }, + ); + return { peak, bytesAccepted: result.bytesAccepted }; + } + + const small = await measure(64 * 1024); + const large = await measure(1024 * 1024); + assert.equal(small.bytesAccepted, 64 * 1024); + assert.equal(large.bytesAccepted, 1024 * 1024); + const bound = 1024 + 256 + 64; + assert.ok(small.peak <= bound, `small peak ${small.peak} exceeded ${bound}`); + assert.ok(large.peak <= bound, `large peak ${large.peak} exceeded ${bound}`); + assert.ok( + Math.abs(large.peak - small.peak) <= 1024, + `peak scaled with input: small=${small.peak} large=${large.peak}`, + ); + }); + + it('applies backpressure and does not pull the whole payload during a slow chunk write', async () => { + const chunkSize = 32; + const total = 32 * 40; + const data = Buffer.alloc(total, 3); + let offset = 0; + let reads = 0; + let writing = false; + let producedWhileWriting = 0; + const source = new Readable({ + highWaterMark: 16, + read() { + reads += 1; + if (offset >= data.length) { + this.push(null); + return; + } + const size = Math.min(16, data.length - offset); + const slice = data.subarray(offset, offset + size); + offset += size; + if (writing) producedWhileWriting += size; + this.push(slice); + }, + }); + let writes = 0; + await consumeReadableChunks(source, 0, total, chunkSize, async () => { + writes += 1; + if (writes === 1) { + writing = true; + await delayed(25); + writing = false; + } + }); + assert.equal(writes, 40); + assert.ok(reads > 1, `expected multiple reads, got ${reads}`); + assert.ok( + producedWhileWriting <= 16 * 4, + `first write pulled ${producedWhileWriting} additional bytes; expected prefetch only, not the full ${total}-byte payload`, + ); + assert.ok(offset === total, 'stream should eventually be consumed'); + }); + + it('rejects a trailing partial after emitting complete chunks, and rejects oversize input', async () => { + const written: number[] = []; + await assert.rejects( + () => consumeReadableChunks( + Readable.from([Buffer.from('abcd'), Buffer.from('xy')]), + 0, + 8, + 4, + async (slice) => { written.push(slice.index); }, + ), + (error: unknown) => codeOf(error) === 'INVALID_ARGUMENT', + ); + assert.deepEqual(written, [0]); + + const oversized: number[] = []; + await assert.rejects( + () => consumeReadableChunks( + Readable.from([Buffer.from('abcdefgh')]), + 0, + 4, + 4, + async (slice) => { oversized.push(slice.bytes.length); }, + ), + (error: unknown) => codeOf(error) === 'INVALID_ARGUMENT', + ); + assert.deepEqual(oversized, [4]); + }); + + it('surfaces an onChunk failure after earlier slices have been emitted', async () => { + const written: number[] = []; + await assert.rejects( + () => consumeReadableChunks( + Readable.from([Buffer.from('aaaabbbbcccc')]), + 0, + 12, + 4, + async (slice) => { + written.push(slice.index); + if (slice.index === 1) throw new Error('write failed'); + }, + ), + /write failed/, + ); + assert.deepEqual(written, [0, 1]); + }); +}); From 7f8bf89cb9d5af0b8d50d0d4d42e79661ad5f9cf Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 17:57:44 +0800 Subject: [PATCH 02/12] chore(budget): raise runtime bundle ceilings for GridFS remaining fixes The remaining-issue change set exceeded the 890000 / 887000 ceilings (892639 CJS / 889922 ESM in the local 2026-11-12 build). The growth is from the incremental Readable append consumer, the beginResumableUpload post-insert files re-check, finalize pending rollback, and rename release-owner alignment; dependencies are unchanged. Raise to 896000 / 893000 to keep about 3 KiB of reviewed headroom. --- config/package-budget.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/package-budget.json b/config/package-budget.json index 19f781d..f0900b8 100644 --- a/config/package-budget.json +++ b/config/package-budget.json @@ -1,8 +1,8 @@ { - "comment": "The 3.5.3 bundle ceilings were 881096 CJS and 878096 ESM. The GridFS cross-operation ownership, durable session CAS/abort fencing, delete protection and idle-TTL migration add ~4.4/4.7 KiB over that ceiling (CI build 2026-11-12: 885536 CJS / 882819 ESM). Raise to 890000 / 887000, keeping ~4 KiB reviewed headroom; no dependency change. Enforce these ceilings in release preflight.", + "comment": "The previous 890000 / 887000 ceilings were exceeded by the GridFS remaining-issue fix (local 2026-11-12 build: 892639 CJS / 889922 ESM). The added bytes are the incremental Readable append consumer, beginResumableUpload post-insert files re-check, finalize pending rollback, and rename release-owner alignment — no dependency change. Raise to 896000 / 893000, keeping ~3 KiB reviewed headroom. Enforce these ceilings in release preflight.", "artifacts": { - "dist/cjs/index.cjs": 890000, - "dist/esm/index.mjs": 887000, + "dist/cjs/index.cjs": 896000, + "dist/esm/index.mjs": 893000, "dist/cjs/cli/data-task.cjs": 50000 } } From 8519c80ec63ccf40f7209705182d49e25200fc91 Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 22:26:28 +0800 Subject: [PATCH 03/12] fix(gridfs): settle premature close, bound inspect, and correct resume docs R1-01: consumeReadableChunks now handles already-destroyed streams and premature close (destroy without error). The read promise rejects in finite time, settles once, waits for an in-flight onChunk write before settling, and removes its listeners on every path so activeAppend can be released. R1-03: gridfs-inspect-orphans streams NDJSON findings, caps each run with maxFindings (default 1000), and exposes an exclusive resumeAfter.filesId cursor. Summary counters accumulate incrementally. The tool remains inspect-only. R1-04: English and Chinese GridFS docs no longer say a failed append returns nextChunk. Clients resume from the last successfully acknowledged position, or from GRIDFS_RESUME_MISMATCH.expectedNextChunk when the stored prefix has moved. --- docs/en/gridfs-recovery.md | 4 +- docs/en/gridfs.md | 2 +- docs/zh/gridfs-recovery.md | 4 +- docs/zh/gridfs.md | 2 +- .../maintenance/gridfs-inspect-orphans.cjs | 118 ++++++++++++++---- src/capabilities/gridfs/stream-chunks.ts | 73 +++++++++-- .../gridfs/gridfs-inspect-orphans.test.ts | 49 ++++++++ .../gridfs/gridfs-remaining-issues.test.ts | 90 +++++++++++++ test/unit/gridfs/gridfs.test.ts | 24 ++++ test/unit/gridfs/stream-chunks.test.ts | 60 +++++++++ 10 files changed, 386 insertions(+), 40 deletions(-) diff --git a/docs/en/gridfs-recovery.md b/docs/en/gridfs-recovery.md index 9031b07..212ff4c 100644 --- a/docs/en/gridfs-recovery.md +++ b/docs/en/gridfs-recovery.md @@ -8,11 +8,11 @@ The ownership registry is the per-bucket `.uploads` collection. A reserv The script `scripts/maintenance/gridfs-recover.cjs` uses a default read-only preview, exact state/token/timestamp checks, a persisted `recovering` ownership claim, verified preservation of committed visible chunks, and an append-only-by-intent audit collection `.ownership_recovery_audit`. It never performs a time-based lock steal. Dry-run plans may use a pre-claim visibility snapshot; **apply always re-reads `files` after the exclusive claim**. If visibility changed, or `verifyVisibleFile` cannot confirm a visible file that the plan required, the tool fails closed and leaves the `recovering` owner in place. -To survey unknown-id leftover chunks without deleting anything, use the inspect-only reporter `scripts/maintenance/gridfs-inspect-orphans.cjs`. It walks `.chunks` in index order with range pagination, re-checks `files` and `uploads` for each `files_id` at classification time, and reports `visible_file`, `owned`, or `candidate_orphan`. It has no apply mode, does not delete, and does not use document age as a deletion criterion. It is not part of the public `GridFSBucket` API. +To survey unknown-id leftover chunks without deleting anything, use the inspect-only reporter `scripts/maintenance/gridfs-inspect-orphans.cjs`. It walks `.chunks` in index order with range pagination, re-checks `files` and `uploads` for each `files_id` at classification time, and reports `visible_file`, `owned`, or `candidate_orphan`. It has no apply mode, does not delete, and does not use document age as a deletion criterion. It is not part of the public `GridFSBucket` API. The CLI emits one NDJSON finding per line plus a final summary object. `--max-findings` (default 1000, cap 100000) bounds memory; a truncated scan returns `resumeAfter.filesId` for the next `--after-files-id`. Summary counters accumulate incrementally. ```sh export MONSQLIZE_RECOVERY_MONGODB_URI='' -node scripts/maintenance/gridfs-inspect-orphans.cjs --db=my_database --bucket=fs --page-size=200 +node scripts/maintenance/gridfs-inspect-orphans.cjs --db=my_database --bucket=fs --page-size=200 --max-findings=1000 ``` ## Deployment and rollback gates (required) diff --git a/docs/en/gridfs.md b/docs/en/gridfs.md index 65e395e..5a890d3 100644 --- a/docs/en/gridfs.md +++ b/docs/en/gridfs.md @@ -81,7 +81,7 @@ Chunk bytes must follow the session size: - Every chunk except the last has length `chunkSizeBytes`. - The last chunk has length `length % chunkSizeBytes`, or `chunkSizeBytes` when the length divides evenly. -- A payload that does not split into those sizes throws `INVALID_ARGUMENT`. For a `Buffer` or `Uint8Array`, the whole payload is planned and checked before any chunk is inserted. For a `Readable`, complete chunks are written as they arrive; a trailing partial or oversize remainder is rejected during consumption, after any complete prefix chunks have been stored. Retry from the reported `nextChunk`. Peak memory is the residual (shorter than the next expected chunk) plus the current incoming stream buffer, not the full payload. Use a modest `highWaterMark` for large files. +- A payload that does not split into those sizes throws `INVALID_ARGUMENT`. For a `Buffer` or `Uint8Array`, the whole payload is planned and checked before any chunk is inserted. For a `Readable`, complete chunks are written as they arrive; a trailing partial or oversize remainder is rejected during consumption, after any complete prefix chunks have been stored. A failed append throws and does not return `nextChunk`. Resume from the last successfully acknowledged position; if the stored prefix has moved, `GRIDFS_RESUME_MISMATCH` includes `expectedNextChunk`. Peak memory is the residual (shorter than the next expected chunk) plus the current incoming stream buffer, not the full payload. Use a modest `highWaterMark` for large files. `appendChunks` is addressed by `fromChunk`: diff --git a/docs/zh/gridfs-recovery.md b/docs/zh/gridfs-recovery.md index 1e0ada4..42139d1 100644 --- a/docs/zh/gridfs-recovery.md +++ b/docs/zh/gridfs-recovery.md @@ -10,11 +10,11 @@ dry-run 可以使用领取所有权之前的可见性快照;**apply 必须在独占 claim 之后重新读取 `files`**。若可见性已经变化,或在需要可见文件时 `verifyVisibleFile` 无法确认,脚本失败并保留 `recovering` 所有权。 -排查未知 ID 的残留 chunks 时,使用只读巡检脚本 `scripts/maintenance/gridfs-inspect-orphans.cjs`。它按已有 `{ files_id, n }` 索引分页扫描 `.chunks`,在分类时重新核对 `files` 和 `uploads`,报告 `visible_file`、`owned` 或 `candidate_orphan`。没有 apply 模式,不会删除,也不会按文档年龄删除。它不是公开的 `GridFSBucket` API。 +排查未知 ID 的残留 chunks 时,使用只读巡检脚本 `scripts/maintenance/gridfs-inspect-orphans.cjs`。它按已有 `{ files_id, n }` 索引分页扫描 `.chunks`,在分类时重新核对 `files` 和 `uploads`,报告 `visible_file`、`owned` 或 `candidate_orphan`。没有 apply 模式,不会删除,也不会按文档年龄删除。它不是公开的 `GridFSBucket` API。CLI 按行输出 NDJSON 结果及一条摘要。`--max-findings`(默认 1000,上限 100000)限制单次内存;截断时用 `resumeAfter.filesId` 作为下次的 `--after-files-id`。摘要计数在扫描过程中增量累计。 ```sh export MONSQLIZE_RECOVERY_MONGODB_URI='<从密钥管理系统读取的 MongoDB URI>' -node scripts/maintenance/gridfs-inspect-orphans.cjs --db=database --bucket=fs --page-size=200 +node scripts/maintenance/gridfs-inspect-orphans.cjs --db=database --bucket=fs --page-size=200 --max-findings=1000 ``` diff --git a/docs/zh/gridfs.md b/docs/zh/gridfs.md index 50d11b2..fb19ca2 100644 --- a/docs/zh/gridfs.md +++ b/docs/zh/gridfs.md @@ -81,7 +81,7 @@ await bucket.abortResumableUpload(session.uploadId); - 除最后一片外,每片长度都是 `chunkSizeBytes`。 - 最后一片长度是 `length % chunkSizeBytes`;能够整除时仍是 `chunkSizeBytes`。 -- 无法按这些长度切开的 payload 抛出 `INVALID_ARGUMENT`。对 `Buffer` / `Uint8Array`,整段 payload 会在插入任何分片之前完成规划与检查。对 `Readable`,完整分片会随读随写;末尾不足或超出的残余会在消费过程中被拒绝,此前已经写入的完整前缀分片会保留,调用方应从返回的 `nextChunk` 重试。峰值内存是“下一片所需长度以内的残余 + 当前流入的 stream 缓冲”,不会随整个 payload 线性增长。大文件应使用较小的 `highWaterMark`。 +- 无法按这些长度切开的 payload 抛出 `INVALID_ARGUMENT`。对 `Buffer` / `Uint8Array`,整段 payload 会在插入任何分片之前完成规划与检查。对 `Readable`,完整分片会随读随写;末尾不足或超出的残余会在消费过程中被拒绝,此前已经写入的完整前缀分片会保留。失败的 append 会抛错,不会返回 `nextChunk`。应从上一次已成功确认的位置继续;若已写入前缀已前进,以 `GRIDFS_RESUME_MISMATCH.expectedNextChunk` 为准。峰值内存是“下一片所需长度以内的残余 + 当前流入的 stream 缓冲”,不会随整个 payload 线性增长。大文件应使用较小的 `highWaterMark`。 `appendChunks` 以 `fromChunk` 定位: diff --git a/scripts/maintenance/gridfs-inspect-orphans.cjs b/scripts/maintenance/gridfs-inspect-orphans.cjs index 8863a0f..95c3f87 100644 --- a/scripts/maintenance/gridfs-inspect-orphans.cjs +++ b/scripts/maintenance/gridfs-inspect-orphans.cjs @@ -8,15 +8,22 @@ * - owned: no files document, but an uploads owner/session exists * - candidate_orphan: no files document and no uploads owner at re-check time * - * This tool never deletes, never applies, and never uses document age as a - * deletion criterion. It is not part of the public GridFSBucket API. + * Findings are streamed (onFinding / CLI NDJSON) and capped by maxFindings. + * Use resumeAfter.filesId (exclusive) to continue a truncated scan. Counters + * are accumulated incrementally for the current invocation only. This tool + * never deletes, never applies, and never uses document age as a deletion + * criterion. It is not part of the public GridFSBucket API. * * Usage: * MONSQLIZE_RECOVERY_MONGODB_URI=... node scripts/maintenance/gridfs-inspect-orphans.cjs \ - * --db=my_database --bucket=fs [--page-size=200] + * --db=my_database --bucket=fs [--page-size=200] [--max-findings=1000] \ + * [--after-files-id=0123456789abcdef01234567] */ const { MongoClient, ObjectId } = require('mongodb'); +const DEFAULT_MAX_FINDINGS = 1000; +const MAX_FINDINGS_CAP = 100000; + function fail(message) { const error = new Error(message); error.code = 'GRIDFS_INSPECT_PRECONDITION_FAILED'; @@ -49,34 +56,72 @@ async function classifyFilesId(db, bucket, group) { return finding; } +function parseAfter(after) { + if (after == null) return null; + const raw = after.filesId ?? after.files_id; + if (raw == null || raw === '') fail('after.filesId is required to resume'); + const filesId = raw instanceof ObjectId ? raw : new ObjectId(String(raw)); + return filesId; +} + async function inspectGridFSOrphans(input) { - const { db, bucket, pageSize = 200 } = input; + const { db, bucket, pageSize = 200, onFinding, collectFindings = true } = input; if (!db || !bucket || typeof bucket !== 'string') fail('Invalid database or bucket'); if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 10000) { fail('pageSize must be an integer between 1 and 10000'); } + const maxFindings = input.maxFindings === undefined ? DEFAULT_MAX_FINDINGS : input.maxFindings; + if (!Number.isInteger(maxFindings) || maxFindings < 1 || maxFindings > MAX_FINDINGS_CAP) { + fail('maxFindings must be an integer between 1 and ' + MAX_FINDINGS_CAP); + } if (input.apply === true) fail('This inspector has no apply mode and never deletes'); + const afterId = parseAfter(input.after || input.resumeAfter); const chunks = db.collection(bucket + '.chunks'); - const findings = []; - let lastId = null; + const findings = collectFindings ? [] : undefined; + let lastId = afterId; let lastN = -1; let current = null; let pages = 0; + let filesIdsScanned = 0; + let visibleFiles = 0; + let owned = 0; + let candidateOrphans = 0; + let truncated = false; + let resumeAfter = null; + let lastClassifiedId = afterId; const flush = async (group) => { - findings.push(await classifyFilesId(db, bucket, group)); + const finding = await classifyFilesId(db, bucket, group); + filesIdsScanned += 1; + if (finding.classification === 'visible_file') visibleFiles += 1; + else if (finding.classification === 'owned') owned += 1; + else candidateOrphans += 1; + if (typeof onFinding === 'function') onFinding(finding); + if (findings) findings.push(finding); + lastClassifiedId = group.id; + if (filesIdsScanned >= maxFindings) { + const more = await chunks.find({ files_id: { $gt: group.id } }).project({ _id: 1 }).limit(1).next(); + if (more) { + truncated = true; + resumeAfter = { + filesId: group.id instanceof ObjectId ? group.id.toHexString() : String(group.id), + }; + } + } }; for (;;) { const filter = lastId == null ? {} - : { - $or: [ - { files_id: { $gt: lastId } }, - { files_id: lastId, n: { $gt: lastN } }, - ], - }; + : lastN < 0 + ? { files_id: { $gt: lastId } } + : { + $or: [ + { files_id: { $gt: lastId } }, + { files_id: lastId, n: { $gt: lastN } }, + ], + }; const page = await chunks.find(filter) .sort({ files_id: 1, n: 1 }) .project({ files_id: 1, n: 1 }) @@ -87,7 +132,13 @@ async function inspectGridFSOrphans(input) { for (const doc of page) { const filesId = doc.files_id; if (!current || !sameId(current.id, filesId)) { - if (current) await flush(current); + if (current) { + await flush(current); + if (truncated) { + current = null; + break; + } + } current = { id: filesId, minN: doc.n, maxN: doc.n, count: 1 }; } else { current.count += 1; @@ -97,20 +148,23 @@ async function inspectGridFSOrphans(input) { lastId = filesId; lastN = doc.n; } + if (truncated) break; if (page.length < pageSize) break; } - if (current) await flush(current); + if (current && !truncated) await flush(current); - const countOf = (name) => findings.filter((row) => row.classification === name).length; return { bucket, pageSize, + maxFindings, pages, - filesIdsScanned: findings.length, - visibleFiles: countOf('visible_file'), - owned: countOf('owned'), - candidateOrphans: countOf('candidate_orphan'), - findings, + filesIdsScanned, + visibleFiles, + owned, + candidateOrphans, + truncated, + resumeAfter, + ...(findings ? { findings } : {}), }; } @@ -139,17 +193,33 @@ async function main() { if (pageSizeArg !== undefined && (!Number.isInteger(pageSize) || pageSize < 1)) { fail('--page-size must be a positive integer'); } + const maxArg = readArg(args, 'max-findings'); + const maxFindings = maxArg === undefined ? DEFAULT_MAX_FINDINGS : Number(maxArg); + if (maxArg !== undefined && (!Number.isInteger(maxFindings) || maxFindings < 1)) { + fail('--max-findings must be a positive integer'); + } + const afterFilesId = readArg(args, 'after-files-id'); const client = new MongoClient(uri); await client.connect(); try { - const report = await inspectGridFSOrphans({ db: client.db(dbName), bucket, pageSize }); - process.stdout.write(JSON.stringify(report, null, 2) + '\n'); + const report = await inspectGridFSOrphans({ + db: client.db(dbName), + bucket, + pageSize, + maxFindings, + after: afterFilesId ? { filesId: afterFilesId } : undefined, + collectFindings: false, + onFinding: (finding) => { + process.stdout.write(JSON.stringify({ type: 'finding', ...finding }) + '\n'); + }, + }); + process.stdout.write(JSON.stringify({ type: 'summary', ...report }) + '\n'); } finally { await client.close(); } } -module.exports = { inspectGridFSOrphans }; +module.exports = { inspectGridFSOrphans, DEFAULT_MAX_FINDINGS }; if (require.main === module) { main().catch((error) => { process.stderr.write(String(error.code || 'INSPECT_ERROR') + ': ' + error.message + '\n'); diff --git a/src/capabilities/gridfs/stream-chunks.ts b/src/capabilities/gridfs/stream-chunks.ts index f3e1ba8..553cbe8 100644 --- a/src/capabilities/gridfs/stream-chunks.ts +++ b/src/capabilities/gridfs/stream-chunks.ts @@ -11,11 +11,23 @@ function badChunkSize(): never { throw invalidArgument('appendChunks', 'data', 'chunk payload must match the session chunk size'); } +function streamClosedError(): Error { + return invalidArgument('appendChunks', 'data', 'readable closed before the upload completed'); +} + +function isPrematureClose(source: Readable, ended: boolean): boolean { + return source.destroyed && !ended && !source.readableEnded; +} + /** * Consume a Readable into GridFS-sized slices without buffering the whole payload. * Retention is limited to a residual shorter than the next expected slice plus the * current incoming `read()` buffer. The stream is not pulled again until the * current complete slices have been awaited, so Node backpressure applies. + * + * A premature `close` (destroy without error) or an already-destroyed source + * rejects in finite time and settles once. An in-flight `onChunk` write is + * awaited before settlement so the caller can release its append claim safely. */ export async function consumeReadableChunks( source: Readable, @@ -30,6 +42,8 @@ export async function consumeReadableChunks( let index = fromChunk; let bytesAccepted = 0; let ended = false; + let aborted = false; + let queuedError: unknown; let busy = false; let settled = false; let pending = false; @@ -76,21 +90,52 @@ export async function consumeReadableChunks( }; await new Promise((resolve, reject) => { + const detach = (): void => { + source.removeListener('readable', onReadable); + source.removeListener('end', onEnd); + source.removeListener('error', onError); + source.removeListener('close', onClose); + }; + const finish = (error?: unknown): void => { if (settled) return; settled = true; + detach(); if (error) reject(error); else resolve(); }; + const abort = (error: unknown): void => { + aborted = true; + queuedError = queuedError ?? error; + if (busy) { + pending = true; + return; + } + finish(queuedError); + }; + + const onReadable = (): void => run(); + const onEnd = (): void => { + ended = true; + run(); + }; + const onError = (error: unknown): void => abort(error); + const onClose = (): void => { + if (ended || source.readableEnded) return; + abort(streamClosedError()); + }; + const pump = async (): Promise => { if (settled || busy) return; busy = true; try { let incoming: Buffer | string | null; - while ((incoming = source.read() as Buffer | string | null) !== null) { + while (!aborted && (incoming = source.read() as Buffer | string | null) !== null) { await takeFrom(Buffer.isBuffer(incoming) ? incoming : Buffer.from(incoming)); } + if (queuedError) throw queuedError; + if (isPrematureClose(source, ended)) throw streamClosedError(); if (ended || source.readableEnded) { ended = true; if (residual.length > 0) badChunkSize(); @@ -106,25 +151,33 @@ export async function consumeReadableChunks( pending = true; return; } + if (queuedError) { + finish(queuedError); + return; + } + if (isPrematureClose(source, ended)) { + finish(streamClosedError()); + return; + } pump().then(() => { if (settled) return; - if (pending) { + if (pending || queuedError || isPrematureClose(source, ended)) { pending = false; run(); return; } - // Only the pump that observed a clean end may resolve. A stale - // success handler must not hide a later residual/size failure. if ((ended || source.readableEnded) && residual.length === 0) finish(); }, (error: unknown) => finish(error)); }; - source.on('readable', run); - source.on('end', () => { - ended = true; - run(); - }); - source.on('error', (error) => finish(error)); + source.on('readable', onReadable); + source.on('end', onEnd); + source.on('error', onError); + source.on('close', onClose); + if (isPrematureClose(source, ended)) { + finish(streamClosedError()); + return; + } run(); if (!ended && source.readableEnded) { ended = true; diff --git a/test/integration/gridfs/gridfs-inspect-orphans.test.ts b/test/integration/gridfs/gridfs-inspect-orphans.test.ts index d7ee58f..6b38317 100644 --- a/test/integration/gridfs/gridfs-inspect-orphans.test.ts +++ b/test/integration/gridfs/gridfs-inspect-orphans.test.ts @@ -117,4 +117,53 @@ describe('GridFS inspect-only orphan reporter', () => { } }); }); + + it('caps findings, streams results, and resumes a large-cardinality scan without deleting', async () => { + await withDb(async (db) => { + const total = 250; + const ids = Array.from({ length: total }, () => new ObjectId()); + ids.sort((left, right) => left.toString().localeCompare(right.toString())); + await db.collection('b.chunks').insertMany(ids.map((id) => ({ + files_id: id, n: 0, data: Buffer.from('DATA'), + }))); + const streamed: string[] = []; + const first = await inspectGridFSOrphans({ + db, bucket: 'b', pageSize: 11, maxFindings: 40, + onFinding: (row: { filesId: string }) => { streamed.push(row.filesId); }, + }); + assert.equal(first.filesIdsScanned, 40); + assert.equal(first.findings.length, 40); + assert.equal(first.truncated, true); + assert.ok(first.resumeAfter?.filesId); + assert.equal(streamed.length, 40); + assert.equal(first.candidateOrphans, 40); + const second = await inspectGridFSOrphans({ + db, bucket: 'b', pageSize: 11, maxFindings: 40, after: first.resumeAfter, + collectFindings: false, + onFinding: (row: { filesId: string }) => { streamed.push(row.filesId); }, + }); + assert.equal(second.findings, undefined); + assert.equal(second.filesIdsScanned, 40); + assert.equal(second.truncated, true); + const rest = await inspectGridFSOrphans({ + db, bucket: 'b', pageSize: 17, maxFindings: 200, after: second.resumeAfter, + }); + assert.equal(rest.truncated, false); + assert.equal(rest.resumeAfter, null); + assert.equal(first.filesIdsScanned + second.filesIdsScanned + rest.filesIdsScanned, total); + const seen = new Set([ + ...first.findings.map((row: { filesId: string }) => row.filesId), + ...rest.findings.map((row: { filesId: string }) => row.filesId), + ...streamed, + ]); + // streamed includes first+second pages; rest.findings is the remainder + const all = new Set([ + ...first.findings.map((row: { filesId: string }) => row.filesId), + ...streamed.slice(40), + ...rest.findings.map((row: { filesId: string }) => row.filesId), + ]); + assert.equal(all.size, total); + assert.equal(await db.collection('b.chunks').countDocuments(), total); + }); + }); }); diff --git a/test/integration/gridfs/gridfs-remaining-issues.test.ts b/test/integration/gridfs/gridfs-remaining-issues.test.ts index 0f7fe2a..ce9b065 100644 --- a/test/integration/gridfs/gridfs-remaining-issues.test.ts +++ b/test/integration/gridfs/gridfs-remaining-issues.test.ts @@ -11,6 +11,10 @@ function sha(data: Buffer): string { return createHash('sha256').update(data).digest('hex'); } +function delayed(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + describe('GridFS remaining-issue regressions (begin race, finalize rollback, streaming append)', () => { const bootstrap = createMemoryServerBootstrap(); let uri = ''; @@ -135,4 +139,90 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str assert.equal(await db.collection('b.chunks').countDocuments({ files_id: otherId }), 1); }); }); + + it('releases activeAppend after a mid-write stream close so retry and abort work', { timeout: 30_000 }, async () => { + await withRuntime('gridfs_stream_close', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const payload = Buffer.from('aaaabbbb'); + const session = await bucket.beginResumableUpload('close.bin', { + length: payload.length, sha256: sha(payload), + }); + let pushed = false; + const source = new Readable({ + read() { + if (pushed) return; + pushed = true; + this.push(payload.subarray(0, 4)); + }, + }); + const original = Collection.prototype.insertOne; + let writeStarted: () => void = () => undefined; + const inFlight = new Promise((resolve) => { writeStarted = resolve; }); + (Collection.prototype as any).insertOne = async function (this: Collection, ...args: unknown[]) { + const document = args[0] as Record; + const filesId = document.files_id as ObjectId | undefined; + if (this.collectionName.endsWith('.chunks') && filesId && String(filesId) === String(session.uploadId)) { + writeStarted(); + await delayed(40); + } + return (original as any).apply(this, args); + }; + const appending = bucket.appendChunks(session.uploadId, 0, source); + try { + await Promise.race([inFlight, delayed(2000)]); + source.destroy(); + await Promise.race([ + assert.rejects( + appending, + (error: unknown) => (error as { code?: string }).code === 'INVALID_ARGUMENT', + ), + delayed(5000).then(() => { + throw new Error('appendChunks did not reject within 5s after stream.destroy()'); + }), + ]); + } finally { + Collection.prototype.insertOne = original; + } + const owner = await db.collection('b.uploads').findOne({ _id: session.uploadId }); + assert.equal(owner?.status, 'pending'); + assert.equal(owner?.activeAppend, undefined); + const stored = await db.collection('b.chunks').countDocuments({ files_id: session.uploadId }); + if (stored === 0) { + const resumed = await bucket.appendChunks(session.uploadId, 0, payload); + assert.equal(resumed.nextChunk, 2); + } else { + await assert.rejects( + bucket.appendChunks(session.uploadId, stored + 1, payload.subarray(stored * 4)), + (error: unknown) => (error as { code?: string }).code === 'GRIDFS_RESUME_MISMATCH' + && (error as { details?: { expectedNextChunk?: number }[] }).details?.[0]?.expectedNextChunk === stored, + ); + const resumed = await bucket.appendChunks(session.uploadId, stored, payload.subarray(stored * 4)); + assert.equal(resumed.nextChunk, 2); + } + await bucket.abortResumableUpload(session.uploadId); + assert.equal(await db.collection('b.uploads').countDocuments({ _id: session.uploadId }), 0); + assert.equal(await db.collection('b.chunks').countDocuments({ files_id: session.uploadId }), 0); + }); + }); + + it('resumes after a streaming append writes one full chunk then rejects a trailing partial', { timeout: 30_000 }, async () => { + await withRuntime('gridfs_stream_resume_partial', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const payload = Buffer.from('aaaabbbb'); + const session = await bucket.beginResumableUpload('partial-resume.bin', { + length: payload.length, sha256: sha(payload), + }); + await assert.rejects( + bucket.appendChunks(session.uploadId, 0, Readable.from([payload.subarray(0, 4), Buffer.from('bb')])), + (error: unknown) => (error as { code?: string }).code === 'INVALID_ARGUMENT', + ); + assert.deepEqual( + (await db.collection('b.chunks').find({ files_id: session.uploadId }).sort({ n: 1 }).toArray()).map((chunk) => chunk.n), + [0], + ); + const resumed = await bucket.appendChunks(session.uploadId, 1, payload.subarray(4)); + assert.equal(resumed.nextChunk, 2); + assert.deepEqual(await bucket.download((await bucket.finalizeResumableUpload(session.uploadId)).id), payload); + }); + }); }); diff --git a/test/unit/gridfs/gridfs.test.ts b/test/unit/gridfs/gridfs.test.ts index d79a94c..c466b43 100644 --- a/test/unit/gridfs/gridfs.test.ts +++ b/test/unit/gridfs/gridfs.test.ts @@ -758,4 +758,28 @@ describe('gridfs bucket', () => { const info = await bucket.finalizeResumableUpload(started.uploadId); assert.equal(info.length, payload.length); }); + + it('resumes a Readable append from expectedNextChunk after one full chunk then a trailing partial', async () => { + const { bucket, db } = bucketFor({ driver: false }); + const payload = Buffer.from('aaaabbbb'); + const started = await bucket.beginResumableUpload('resume.bin', { + length: payload.length, sha256: sha(payload), chunkSizeBytes: 4, + }); + await assert.rejects( + () => bucket.appendChunks(started.uploadId, 0, Readable.from([payload.subarray(0, 4), Buffer.from('bb')])), + (error: unknown) => codeOf(error) === 'INVALID_ARGUMENT', + ); + assert.deepEqual( + (await db.collection('fs.chunks').find({ files_id: started.uploadId }).sort({ n: 1 }).toArray()).map((chunk) => chunk.n), + [0], + ); + assert.equal((await db.collection('fs.uploads').findOne({ _id: started.uploadId }))?.activeAppend, undefined); + await assert.rejects( + () => bucket.appendChunks(started.uploadId, 2, payload.subarray(4)), + (error: unknown) => codeOf(error) === 'GRIDFS_RESUME_MISMATCH' && detailsOf(error).expectedNextChunk === 1, + ); + const resumed = await bucket.appendChunks(started.uploadId, 1, payload.subarray(4)); + assert.equal(resumed.nextChunk, 2); + assert.equal((await bucket.finalizeResumableUpload(started.uploadId)).length, 8); + }); }); diff --git a/test/unit/gridfs/stream-chunks.test.ts b/test/unit/gridfs/stream-chunks.test.ts index e093f3f..46f4f8e 100644 --- a/test/unit/gridfs/stream-chunks.test.ts +++ b/test/unit/gridfs/stream-chunks.test.ts @@ -11,6 +11,22 @@ function delayed(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function settles(promise: Promise, ms = 1000): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`did not settle within ${ms}ms`)), ms); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function isClosedError(error: unknown): boolean { + return codeOf(error) === 'INVALID_ARGUMENT' && String((error as Error).message).includes('closed before the upload completed'); +} + function readableFromBuffer(data: Buffer, highWaterMark: number): Readable { let offset = 0; return new Readable({ @@ -145,4 +161,48 @@ describe('consumeReadableChunks', () => { ); assert.deepEqual(written, [0, 1]); }); + + it('rejects a close-only destroy without hanging', async () => { + const source = new Readable({ read() { /* never ends */ } }); + const consumption = consumeReadableChunks(source, 0, 8, 4, async () => undefined); + setImmediate(() => source.destroy()); + await assert.rejects(() => settles(consumption), isClosedError); + assert.equal(source.listenerCount('readable'), 0); + assert.equal(source.listenerCount('close'), 0); + }); + + it('rejects a stream that is already destroyed before it is passed in', async () => { + const source = new Readable({ read() { /* never ends */ } }); + source.destroy(); + assert.equal(source.destroyed, true); + assert.equal(source.readableEnded, false); + await assert.rejects( + () => settles(consumeReadableChunks(source, 0, 8, 4, async () => undefined)), + isClosedError, + ); + }); + + it('waits for an in-flight onChunk write before rejecting a mid-write destroy', async () => { + let writeFinished = false; + let pushed = false; + const source = new Readable({ + read() { + if (!pushed) { + pushed = true; + this.push(Buffer.from('aaaa')); + } + }, + }); + const started = Date.now(); + await assert.rejects( + () => settles(consumeReadableChunks(source, 0, 8, 4, async () => { + source.destroy(); + await delayed(40); + writeFinished = true; + })), + isClosedError, + ); + assert.equal(writeFinished, true); + assert.ok(Date.now() - started >= 40, 'rejected before the in-flight write finished'); + }); }); From af1603053fdb8cf11e4c5a3ddb854d0096576a57 Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 22:26:31 +0800 Subject: [PATCH 04/12] chore(budget): raise website search-index ceiling for GridFS doc growth After the R1 documentation corrections, the English search index measures 1800511 bytes, 511 over the 1800000 ceiling. CI run 43196147300 already failed at 1800085 on the previous PR head; this round added the failed append resume contract (R1-04) and inspect memory-bound run notes (R1-03) on the GridFS and recovery pages. The Chinese index is 1672743 and the combined total 3473254 remains under 3500000. The growth is from GridFS remaining-issue documentation, not from new website dependencies. The maintainer explicitly authorized raising this ceiling instead of trimming documentation. Raise maximumSearchIndexBytesPerLocale from 1800000 to 1804000 to keep about 3.4 KiB of reviewed headroom. --- website/site-budget.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/website/site-budget.json b/website/site-budget.json index fee3213..624e311 100644 --- a/website/site-budget.json +++ b/website/site-budget.json @@ -4,7 +4,10 @@ "maximumTotalBytes": 68000000, "maximumSingleJavaScriptBytes": 740000, "maximumSingleHtmlBytes": 550000, - "maximumSearchIndexBytesPerLocale": 1800000, + "maximumSearchIndexBytesPerLocale": 1804000, "maximumCombinedSearchIndexBytes": 3500000, - "requiredSearchLocales": ["en", "zh"] + "requiredSearchLocales": [ + "en", + "zh" + ] } From 8af814eaa56e00176b37b6af2893b147ba2d0f7a Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 09:16:36 +0800 Subject: [PATCH 05/12] fix(gridfs): reject invalid recovery state and chunk indexes --- docs/en/gridfs.md | 4 +-- docs/zh/gridfs.md | 4 +-- scripts/maintenance/gridfs-recover.cjs | 18 ++++++++---- src/capabilities/gridfs/resumable.ts | 13 +++++---- src/capabilities/gridfs/stream-chunks.ts | 7 +++-- .../gridfs/gridfs-remaining-issues.test.ts | 26 +++++++++++++++++ .../gridfs/gridfs-stale-recovery.test.ts | 28 +++++++++++++++++++ test/unit/gridfs/stream-chunks.test.ts | 19 +++++++------ 8 files changed, 92 insertions(+), 27 deletions(-) diff --git a/docs/en/gridfs.md b/docs/en/gridfs.md index 5a890d3..9d60430 100644 --- a/docs/en/gridfs.md +++ b/docs/en/gridfs.md @@ -92,7 +92,7 @@ Chunk bytes must follow the session size: - A payload that contains any new chunk must start at `nextChunk`. Starting earlier throws `GRIDFS_RESUME_MISMATCH` and writes nothing. - A duplicate `{ files_id, n }` insert is accepted only when the stored bytes match. -`finalizeResumableUpload` requires a contiguous prefix, a total length equal to the session length, and a sha256 equal to the session digest. It then inserts the files document, sets `metadata.sha256`, and deletes the session. Missing chunk indexes throw `GRIDFS_UPLOAD_INCOMPLETE` with `details[0].missingChunks`. A bad digest or a chunk of the wrong size throws `GRIDFS_INTEGRITY_MISMATCH`. The files document is not inserted in those cases. +`finalizeResumableUpload` requires exactly one chunk at each expected index starting at zero, a total length equal to the session length, and a sha256 equal to the session digest. It then inserts the files document, sets `metadata.sha256`, and deletes the session. Missing chunk indexes throw `GRIDFS_UPLOAD_INCOMPLETE` with `details[0].missingChunks`. An invalid, duplicate, or out-of-range chunk index, a bad digest, or a chunk of the wrong size throws `GRIDFS_INTEGRITY_MISMATCH`. The files document is not inserted in those cases. Retrying `finalizeResumableUpload` is idempotent while the session still exists: if a file with the same id is already visible, the library re-hashes the actual stored chunks and returns the file only when that digest and the file length match the session. A file with a different sha256 or length still throws `GRIDFS_FILE_EXISTS`. Once the session is gone (a finalize that fully succeeded, an abort, or the TTL), there is no digest to compare, so a retry throws `GRIDFS_FILE_NOT_FOUND`. Callers must persist `uploadId`, `length`, and `sha256` themselves. After a vanished session, use `getFile(uploadId)` to see whether a file is visible, then re-hash the **actual** bytes (for example `download(uploadId)` or a walk of `.chunks`) and compare them to the persisted length and digest. Never treat `metadata.sha256` alone as proof: `uploadStream()` and a raw `metadata.sha256` can store an unverified claim. This package does not add a separate public verification API. @@ -138,7 +138,7 @@ A blocked call throws `INVALID_OPERATION` before any GridFS write. `details[0]` |------|------| | `GRIDFS_FILE_NOT_FOUND` | Download, delete, rename, or resumable session target is missing | | `GRIDFS_FILE_EXISTS` | `ifExists: 'error'` finds a conflict, or the chosen id is already present | -| `GRIDFS_INTEGRITY_MISMATCH` | Bytes do not match `sha256`, or a finalized chunk has the wrong size | +| `GRIDFS_INTEGRITY_MISMATCH` | Bytes do not match `sha256`, or finalize found an invalid chunk index or size | | `GRIDFS_UPLOAD_INCOMPLETE` | Finalize found gaps. `details[0].missingChunks` lists them | | `GRIDFS_RESUME_MISMATCH` | `fromChunk` is ahead of the contiguous prefix, or a replayed chunk differs | | `GRIDFS_UPLOAD_BUSY` | Another append/finalize/abort/upload/delete operation owns the ID; do not retry until it finishes or is explicitly recovered | diff --git a/docs/zh/gridfs.md b/docs/zh/gridfs.md index fb19ca2..14ebc36 100644 --- a/docs/zh/gridfs.md +++ b/docs/zh/gridfs.md @@ -92,7 +92,7 @@ await bucket.abortResumableUpload(session.uploadId); - 只要 payload 含有新分片,`fromChunk` 就必须等于 `nextChunk`。从更早的序号开始会抛出 `GRIDFS_RESUME_MISMATCH`,并且不会写入。 - `{ files_id, n }` 唯一索引冲突只有在已存字节相同的时候才算成功。 -`finalizeResumableUpload` 要求分片从 0 连续、总长度等于会话长度,且 sha256 等于会话摘要。然后插入 files 文档、写入 `metadata.sha256`,并删除会话。缺少的分片序号抛出 `GRIDFS_UPLOAD_INCOMPLETE`,`details[0].missingChunks` 列出缺口。摘要不符或分片长度不对抛出 `GRIDFS_INTEGRITY_MISMATCH`。这些失败都不会插入 files 文档。 +`finalizeResumableUpload` 要求从 0 开始的每个预期序号恰好对应一个分片,总长度等于会话长度,且 sha256 等于会话摘要。然后插入 files 文档、写入 `metadata.sha256`,并删除会话。缺少的分片序号抛出 `GRIDFS_UPLOAD_INCOMPLETE`,`details[0].missingChunks` 列出缺口。序号无效、重复或越界,以及摘要或分片长度不符,均抛出 `GRIDFS_INTEGRITY_MISMATCH`。这些失败都不会插入 files 文档。 会话仍在时,重试 `finalizeResumableUpload` 是幂等的:若同 id 的文件已可见,库会重新哈希**实际存储的 chunks**,只有摘要和长度都与会话一致时才返回该文件并删除会话。sha256 或长度不同的文件仍抛 `GRIDFS_FILE_EXISTS`。会话已不存在时(上一次 finalize 已完整成功、已 abort 或 TTL 过期),没有可比对的摘要,重试抛 `GRIDFS_FILE_NOT_FOUND`。调用方必须自己持久化 `uploadId`、`length` 和 `sha256`。会话消失后,先用 `getFile(uploadId)` 看文件是否可见,再对**实际字节**重新哈希(例如 `download(uploadId)` 或遍历 `.chunks`),与自己保存的长度和摘要比对。不要只信任 `metadata.sha256`:`uploadStream()` 和调用方写入的 `metadata.sha256` 可以是未校验的声明。本包不为此再提供新的公开校验 API。 @@ -138,7 +138,7 @@ GridFS 写入的类别是 `gridfs`。命名空间是 `.files`,匹配 |--------|------| | `GRIDFS_FILE_NOT_FOUND` | 下载、删除、重命名或可恢复会话的目标不存在 | | `GRIDFS_FILE_EXISTS` | `ifExists: 'error'` 发现冲突,或指定 id 已经存在 | -| `GRIDFS_INTEGRITY_MISMATCH` | 字节与 `sha256` 不符,或 finalize 时分片长度不对 | +| `GRIDFS_INTEGRITY_MISMATCH` | 字节与 `sha256` 不符,或 finalize 时发现无效的分片序号或长度 | | `GRIDFS_UPLOAD_INCOMPLETE` | finalize 发现缺口。`details[0].missingChunks` 列出缺少的序号 | | `GRIDFS_RESUME_MISMATCH` | `fromChunk` 超前于连续前缀,或重放的分片内容不同 | | `GRIDFS_UPLOAD_BUSY` | 其他 append/finalize/abort/upload/delete 操作正在占用该 ID,必须等待完成或在确认旧进程停止后显式恢复 | diff --git a/scripts/maintenance/gridfs-recover.cjs b/scripts/maintenance/gridfs-recover.cjs index aad5fda..434eec0 100644 --- a/scripts/maintenance/gridfs-recover.cjs +++ b/scripts/maintenance/gridfs-recover.cjs @@ -13,6 +13,15 @@ const { randomUUID, createHash } = require('node:crypto'); const { MongoClient, ObjectId } = require('mongodb'); const TTL_MS = 604800000; +const RECOVERY_ORIGIN_KINDS = new Map([ + ['uploading', 'direct'], + ['deleting', 'delete'], + ['renaming', 'rename'], + ['cleaning', 'cleanup'], + ['activeAppend', 'resumable'], + ['finalizing', 'resumable'], + ['aborting', 'resumable'], +]); function fail(message) { const error = new Error(message); @@ -117,12 +126,9 @@ function classify(doc) { } // Also validate the persisted origin when a crash left status=recovering: // do not allow malformed/corrupt origin metadata to unlock arbitrary IDs. - const requiredKind = { - uploading: 'direct', deleting: 'delete', renaming: 'rename', cleaning: 'cleanup', - }; - if (!state || (requiredKind[state] && doc.kind !== requiredKind[state]) || - (['activeAppend', 'finalizing', 'aborting'].includes(state) && - doc.kind && doc.kind !== 'resumable') || + const requiredKind = RECOVERY_ORIGIN_KINDS.get(state); + if (!requiredKind || + (doc.kind !== requiredKind && !(requiredKind === 'resumable' && !doc.kind)) || (state === 'activeAppend' && !doc.activeAppend?.token)) { fail('Inconsistent or unsupported owner origin; preserve ownership'); } diff --git a/src/capabilities/gridfs/resumable.ts b/src/capabilities/gridfs/resumable.ts index dc0f885..dfa0de8 100644 --- a/src/capabilities/gridfs/resumable.ts +++ b/src/capabilities/gridfs/resumable.ts @@ -232,15 +232,16 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje const cursor = ctx.db.collection(chunksCollectionName(ctx.bucketName)).find({ files_id: uploadId }).sort({ n: 1 }); let seen = 0; for await (const doc of cursor) { - const n = typeof doc.n === 'number' ? doc.n : -1; - while (seen < n && seen < expected) { + const n = doc.n; + if (!Number.isSafeInteger(n) || n < seen || n >= expected) { + throw integrityMismatch('finalizeResumableUpload', ctx.bucketName, { + uploadId, expected: `chunk index between ${seen} and ${expected - 1}`, actual: n, + }); + } + while (seen < n) { missing.push(seen); seen += 1; } - if (n !== seen || n < 0 || n >= expected) { - if (n >= 0) missing.push(n); - continue; - } const bytes = binaryToBuffer(doc.data, 'finalizeResumableUpload'); const want = chunkLengthAt(n, session.length, session.chunkSizeBytes); if (bytes.length !== want) { diff --git a/src/capabilities/gridfs/stream-chunks.ts b/src/capabilities/gridfs/stream-chunks.ts index 553cbe8..dfe2916 100644 --- a/src/capabilities/gridfs/stream-chunks.ts +++ b/src/capabilities/gridfs/stream-chunks.ts @@ -27,7 +27,7 @@ function isPrematureClose(source: Readable, ended: boolean): boolean { * * A premature `close` (destroy without error) or an already-destroyed source * rejects in finite time and settles once. An in-flight `onChunk` write is - * awaited before settlement so the caller can release its append claim safely. + * awaited before settlement, and any remaining buffered slices are discarded. */ export async function consumeReadableChunks( source: Readable, @@ -62,7 +62,9 @@ export async function consumeReadableChunks( const takeFrom = async (incoming: Buffer): Promise => { let offset = 0; report(incoming.length); - while (index < total && offset < incoming.length) { + while (offset < incoming.length) { + if (aborted || isPrematureClose(source, ended)) return; + if (index >= total) badChunkSize(); const want = chunkLengthAt(index, length, chunkSizeBytes); const have = residual.length + (incoming.length - offset); if (have < want) { @@ -85,7 +87,6 @@ export async function consumeReadableChunks( } await emit({ index, bytes }); } - if (index >= total && offset < incoming.length) badChunkSize(); report(); }; diff --git a/test/integration/gridfs/gridfs-remaining-issues.test.ts b/test/integration/gridfs/gridfs-remaining-issues.test.ts index ce9b065..e4d98f8 100644 --- a/test/integration/gridfs/gridfs-remaining-issues.test.ts +++ b/test/integration/gridfs/gridfs-remaining-issues.test.ts @@ -106,6 +106,32 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str }); }); + it('rejects malformed or extra chunk indexes even when the expected bytes are present', async () => { + await withRuntime('gridfs_finalize_invalid_index', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const payload = Buffer.from('GOOD'); + for (const n of [-1, 'invalid', 0.5, 1, Number.MAX_SAFE_INTEGER + 1]) { + const { uploadId } = await bucket.beginResumableUpload(`bad-index-${n}.bin`, { + length: payload.length, sha256: sha(payload), + }); + await bucket.appendChunks(uploadId, 0, payload); + const chunks = db.collection('b.chunks'); + const extra = await chunks.insertOne({ files_id: uploadId, n, data: Buffer.from('BAD!') }); + + await assert.rejects(bucket.finalizeResumableUpload(uploadId), { + code: 'GRIDFS_INTEGRITY_MISMATCH', + }, `chunk index ${n}`); + assert.equal(await db.collection('b.files').findOne({ _id: uploadId }), null); + assert.equal((await db.collection('b.uploads').findOne({ _id: uploadId }))?.status, 'pending'); + assert.equal(await chunks.countDocuments({ files_id: uploadId }), 2); + + await chunks.deleteOne({ _id: extra.insertedId }); + const info = await bucket.finalizeResumableUpload(uploadId); + assert.deepEqual(await bucket.download(info.id), payload); + } + }); + }); + it('streams a Readable append with ownership, idempotent replay, and recoverable partial failure', { timeout: 30_000 }, async () => { await withRuntime('gridfs_stream_append', async (runtime, db) => { const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); diff --git a/test/integration/gridfs/gridfs-stale-recovery.test.ts b/test/integration/gridfs/gridfs-stale-recovery.test.ts index a0426ba..6a22556 100644 --- a/test/integration/gridfs/gridfs-stale-recovery.test.ts +++ b/test/integration/gridfs/gridfs-stale-recovery.test.ts @@ -295,6 +295,34 @@ describe('GridFS operator recovery: deterministic post-crash state and chunk saf }); }); + it('rejects an unknown recovery origin before changing ownership or chunks', async () => { + await withRuntime(async (bucket, db) => { + const payload = Buffer.from('GOOD'); + const { uploadId: id } = await bucket.beginResumableUpload('interrupted.bin', { + length: payload.length, sha256: digest(payload), + }); + await bucket.appendChunks(id, 0, payload); + const uploads = db.collection('b.uploads'); + await uploads.updateOne({ _id: id }, { + $set: { + status: 'recovering', updatedAt: new Date(), + recovery: { token: 'recovery-token', fromStatus: 'unknown' }, + }, + $unset: { expiresAt: '' }, + }); + const owner = await uploads.findOne({ _id: id }); + const chunks = db.collection('b.chunks'); + const originalChunks = await chunks.find({ files_id: id }).toArray(); + + await assert.rejects(recover(db, id, { resumeQuiesced: true }), { + code: 'GRIDFS_RECOVERY_PRECONDITION_FAILED', + }); + assert.deepEqual(await uploads.findOne({ _id: id }), owner); + assert.deepEqual(await chunks.find({ files_id: id }).toArray(), originalChunks); + assert.equal(await db.collection('b.ownership_recovery_audit').countDocuments({ fileId: id }), 0); + }); + }); + it('refuses to clear aborting ownership if a visible file already owns the chunks', async () => { await withRuntime(async (bucket, db) => { const id = new ObjectId(); diff --git a/test/unit/gridfs/stream-chunks.test.ts b/test/unit/gridfs/stream-chunks.test.ts index 46f4f8e..a14887f 100644 --- a/test/unit/gridfs/stream-chunks.test.ts +++ b/test/unit/gridfs/stream-chunks.test.ts @@ -182,27 +182,30 @@ describe('consumeReadableChunks', () => { ); }); - it('waits for an in-flight onChunk write before rejecting a mid-write destroy', async () => { + it('waits for the in-flight write and discards the remaining buffered chunks on destroy', async () => { let writeFinished = false; let pushed = false; + const startedWrites: number[] = []; const source = new Readable({ read() { if (!pushed) { pushed = true; - this.push(Buffer.from('aaaa')); + this.push(Buffer.from('aaaabbbbcccc')); } }, }); - const started = Date.now(); await assert.rejects( - () => settles(consumeReadableChunks(source, 0, 8, 4, async () => { - source.destroy(); - await delayed(40); - writeFinished = true; + () => settles(consumeReadableChunks(source, 0, 12, 4, async ({ index }) => { + startedWrites.push(index); + if (index === 0) { + source.destroy(); + await delayed(40); + writeFinished = true; + } })), isClosedError, ); assert.equal(writeFinished, true); - assert.ok(Date.now() - started >= 40, 'rejected before the in-flight write finished'); + assert.deepEqual(startedWrites, [0]); }); }); From 4b0fd46d32640a8ab46653ca0386934e4ce92f6e Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 09:46:42 +0800 Subject: [PATCH 06/12] fix(gridfs): fence upload initialization and settle stream teardown --- .github/workflows/gridfs-fix-validation.yml | 5 +- config/package-budget.json | 6 +- docs/en/gridfs-recovery.md | 3 +- docs/en/gridfs.md | 4 +- docs/zh/gridfs-recovery.md | 5 +- docs/zh/gridfs.md | 4 +- .../maintenance/gridfs-inspect-orphans.cjs | 80 ++++-------- scripts/maintenance/gridfs-recover.cjs | 8 +- src/capabilities/gridfs/resumable.ts | 77 ++++++----- src/capabilities/gridfs/stream-chunks.ts | 59 +++++++-- src/capabilities/gridfs/types.ts | 2 +- .../gridfs/gridfs-inspect-orphans.test.ts | 55 +++++++- .../gridfs/gridfs-remaining-issues.test.ts | 123 ++++++++++++++++++ .../gridfs/gridfs-stale-recovery.test.ts | 23 ++++ test/unit/gridfs/stream-chunks.test.ts | 26 ++++ 15 files changed, 366 insertions(+), 114 deletions(-) diff --git a/.github/workflows/gridfs-fix-validation.yml b/.github/workflows/gridfs-fix-validation.yml index 48b72b8..c139c92 100644 --- a/.github/workflows/gridfs-fix-validation.yml +++ b/.github/workflows/gridfs-fix-validation.yml @@ -42,7 +42,10 @@ jobs: run: | node --test --test-concurrency=1 \ .generated/test-dist/test/unit/gridfs/gridfs.test.js \ + .generated/test-dist/test/unit/gridfs/stream-chunks.test.js \ .generated/test-dist/test/integration/gridfs/gridfs.test.js \ .generated/test-dist/test/integration/gridfs/gridfs-scan-regressions.test.js \ .generated/test-dist/test/integration/gridfs/gridfs-ownership-regressions.test.js \ - .generated/test-dist/test/integration/gridfs/gridfs-stale-recovery.test.js + .generated/test-dist/test/integration/gridfs/gridfs-stale-recovery.test.js \ + .generated/test-dist/test/integration/gridfs/gridfs-remaining-issues.test.js \ + .generated/test-dist/test/integration/gridfs/gridfs-inspect-orphans.test.js diff --git a/config/package-budget.json b/config/package-budget.json index f0900b8..ac57acb 100644 --- a/config/package-budget.json +++ b/config/package-budget.json @@ -1,8 +1,8 @@ { - "comment": "The previous 890000 / 887000 ceilings were exceeded by the GridFS remaining-issue fix (local 2026-11-12 build: 892639 CJS / 889922 ESM). The added bytes are the incremental Readable append consumer, beginResumableUpload post-insert files re-check, finalize pending rollback, and rename release-owner alignment — no dependency change. Raise to 896000 / 893000, keeping ~3 KiB reviewed headroom. Enforce these ceilings in release preflight.", + "comment": "GridFS initialization fencing and Readable teardown bring the 2026-11-13 build to about 896200 CJS and 893500 ESM, with no dependency change. Ceilings retain less than 1 KiB of headroom for these measured builds and are enforced in release preflight.", "artifacts": { - "dist/cjs/index.cjs": 896000, - "dist/esm/index.mjs": 893000, + "dist/cjs/index.cjs": 897000, + "dist/esm/index.mjs": 894000, "dist/cjs/cli/data-task.cjs": 50000 } } diff --git a/docs/en/gridfs-recovery.md b/docs/en/gridfs-recovery.md index 212ff4c..7eba8d6 100644 --- a/docs/en/gridfs-recovery.md +++ b/docs/en/gridfs-recovery.md @@ -8,7 +8,7 @@ The ownership registry is the per-bucket `.uploads` collection. A reserv The script `scripts/maintenance/gridfs-recover.cjs` uses a default read-only preview, exact state/token/timestamp checks, a persisted `recovering` ownership claim, verified preservation of committed visible chunks, and an append-only-by-intent audit collection `.ownership_recovery_audit`. It never performs a time-based lock steal. Dry-run plans may use a pre-claim visibility snapshot; **apply always re-reads `files` after the exclusive claim**. If visibility changed, or `verifyVisibleFile` cannot confirm a visible file that the plan required, the tool fails closed and leaves the `recovering` owner in place. -To survey unknown-id leftover chunks without deleting anything, use the inspect-only reporter `scripts/maintenance/gridfs-inspect-orphans.cjs`. It walks `.chunks` in index order with range pagination, re-checks `files` and `uploads` for each `files_id` at classification time, and reports `visible_file`, `owned`, or `candidate_orphan`. It has no apply mode, does not delete, and does not use document age as a deletion criterion. It is not part of the public `GridFSBucket` API. The CLI emits one NDJSON finding per line plus a final summary object. `--max-findings` (default 1000, cap 100000) bounds memory; a truncated scan returns `resumeAfter.filesId` for the next `--after-files-id`. Summary counters accumulate incrementally. +To survey unknown-id leftover chunks without deleting anything, use the inspect-only reporter `scripts/maintenance/gridfs-inspect-orphans.cjs`. It walks `.chunks` in index order in bounded batches, re-checks `files` after finding neither a file nor an owner for a `files_id`, and reports `visible_file`, `owned`, or `candidate_orphan`. It has no apply mode, does not delete, and does not use document age as a deletion criterion. It is not part of the public `GridFSBucket` API. The CLI emits one NDJSON finding per line plus a final summary object. `--max-findings` (default 1000, cap 100000) bounds memory; a truncated scan returns `resumeAfter.filesId` for the next `--after-files-id`. Summary counters accumulate incrementally. ```sh export MONSQLIZE_RECOVERY_MONGODB_URI='' @@ -61,6 +61,7 @@ Every apply attempt includes an audit entry. A completed visible-file verificati | `kind:direct,status:uploading` | Verify chunk sequence, per-chunk length and total length. Enforce SHA only when the owner records a pre-verified `upload()` expected digest; otherwise record any metadata mismatch as an audit warning | `uploadStream()` and arbitrary `metadata.sha256` are untrusted claims; visible-file chunks are never deleted | | `kind:delete,status:deleting` | Complete the interrupted delete of file and chunks before releasing ownership | No new upload may claim the same ID until both steps finish | | `kind:cleanup,status:cleaning` | Check visible file; delete chunks only if no file exists; release | Never delete a visible file's chunks | +| `kind:resumable,status:initializing` | Preserve visible chunks or remove invisible leftovers, then release the initialization claim | Retry `beginResumableUpload` after recovery if no file is visible | | `kind:rename,status:renaming` | Verify the resulting visible file has complete chunks, then release; missing or corrupt files remain locked | The tool does not choose or replay a rename target; the operator must inspect the intended filename | | `status:pending,activeAppend` | Recompute the contiguous chunk prefix, clear only the observed append claim, restore seven-day idle expiry | Existing chunks are preserved; caller replays/checks the data | | `status:finalizing` without file | Restore pending with expiry; retry normal finalize, which revalidates bytes | Never discard chunks to unlock finalize | diff --git a/docs/en/gridfs.md b/docs/en/gridfs.md index 9d60430..9c91685 100644 --- a/docs/en/gridfs.md +++ b/docs/en/gridfs.md @@ -75,7 +75,7 @@ const info = await bucket.finalizeResumableUpload(session.uploadId); await bucket.abortResumableUpload(session.uploadId); ``` -`beginResumableUpload` requires `length` and `sha256`. An id that already exists in `files` or `uploads` throws `GRIDFS_FILE_EXISTS`. Those prechecks are not a lock: the session insert is the atomic id claim. After the insert, `files` is checked again. If a file became visible in that window, the new pending session is dropped (best effort), chunks of the visible file are never deleted, and the call throws `GRIDFS_FILE_EXISTS`. +`beginResumableUpload` requires `length` and `sha256`. An id already present in `files` or `uploads` throws `GRIDFS_FILE_EXISTS`. The insert claims the id in `initializing` without expiry. It rechecks `files` and cleans old chunks before making the session `pending`. While initialization runs, other writes to that id are blocked. If a file became visible before the recheck, the call preserves its chunks and throws `GRIDFS_FILE_EXISTS`. An interrupted initialization can be cleared through operator recovery after the writer has stopped. Chunk bytes must follow the session size: @@ -94,7 +94,7 @@ Chunk bytes must follow the session size: `finalizeResumableUpload` requires exactly one chunk at each expected index starting at zero, a total length equal to the session length, and a sha256 equal to the session digest. It then inserts the files document, sets `metadata.sha256`, and deletes the session. Missing chunk indexes throw `GRIDFS_UPLOAD_INCOMPLETE` with `details[0].missingChunks`. An invalid, duplicate, or out-of-range chunk index, a bad digest, or a chunk of the wrong size throws `GRIDFS_INTEGRITY_MISMATCH`. The files document is not inserted in those cases. -Retrying `finalizeResumableUpload` is idempotent while the session still exists: if a file with the same id is already visible, the library re-hashes the actual stored chunks and returns the file only when that digest and the file length match the session. A file with a different sha256 or length still throws `GRIDFS_FILE_EXISTS`. Once the session is gone (a finalize that fully succeeded, an abort, or the TTL), there is no digest to compare, so a retry throws `GRIDFS_FILE_NOT_FOUND`. Callers must persist `uploadId`, `length`, and `sha256` themselves. After a vanished session, use `getFile(uploadId)` to see whether a file is visible, then re-hash the **actual** bytes (for example `download(uploadId)` or a walk of `.chunks`) and compare them to the persisted length and digest. Never treat `metadata.sha256` alone as proof: `uploadStream()` and a raw `metadata.sha256` can store an unverified claim. This package does not add a separate public verification API. +Retrying `finalizeResumableUpload` is idempotent while the session still exists: if a file with the same id is already visible, the library re-hashes the actual stored chunks and returns the file only when that digest, the file length, and the chunk size match the session. A different sha256 or length throws `GRIDFS_FILE_EXISTS`. A chunk-size mismatch throws `GRIDFS_INTEGRITY_MISMATCH` and retains the existing finalizing session. Once the session is gone (a finalize that fully succeeded, an abort, or the TTL), there is no digest to compare, so a retry throws `GRIDFS_FILE_NOT_FOUND`. Callers must persist `uploadId`, `length`, and `sha256` themselves. After a vanished session, use `getFile(uploadId)` to see whether a file is visible, then re-hash the **actual** bytes (for example `download(uploadId)` or a walk of `.chunks`) and compare them to the persisted length and digest. Never treat `metadata.sha256` alone as proof: `uploadStream()` and a raw `metadata.sha256` can store an unverified claim. This package does not add a separate public verification API. A `finalizeResumableUpload` that fails with `GRIDFS_FILE_EXISTS` or `GRIDFS_INTEGRITY_MISMATCH` after it has claimed `finalizing` rolls the session back to `pending` so `abortResumableUpload` (and then a delete of a conflicting visible file) can proceed through the public API. Ambiguous driver errors during the files insert still leave `finalizing` for explicit recovery. diff --git a/docs/zh/gridfs-recovery.md b/docs/zh/gridfs-recovery.md index 42139d1..20489fb 100644 --- a/docs/zh/gridfs-recovery.md +++ b/docs/zh/gridfs-recovery.md @@ -10,7 +10,7 @@ dry-run 可以使用领取所有权之前的可见性快照;**apply 必须在独占 claim 之后重新读取 `files`**。若可见性已经变化,或在需要可见文件时 `verifyVisibleFile` 无法确认,脚本失败并保留 `recovering` 所有权。 -排查未知 ID 的残留 chunks 时,使用只读巡检脚本 `scripts/maintenance/gridfs-inspect-orphans.cjs`。它按已有 `{ files_id, n }` 索引分页扫描 `.chunks`,在分类时重新核对 `files` 和 `uploads`,报告 `visible_file`、`owned` 或 `candidate_orphan`。没有 apply 模式,不会删除,也不会按文档年龄删除。它不是公开的 `GridFSBucket` API。CLI 按行输出 NDJSON 结果及一条摘要。`--max-findings`(默认 1000,上限 100000)限制单次内存;截断时用 `resumeAfter.filesId` 作为下次的 `--after-files-id`。摘要计数在扫描过程中增量累计。 +排查未知 ID 的残留 chunks 时,使用只读巡检脚本 `scripts/maintenance/gridfs-inspect-orphans.cjs`。它按已有 `{ files_id, n }` 索引分批扫描 `.chunks`。分类时若未查到 `files` 和 `uploads` 记录,会再次核对 `files`,报告 `visible_file`、`owned` 或 `candidate_orphan`。没有 apply 模式,不会删除,也不会按文档年龄删除。它不是公开的 `GridFSBucket` API。CLI 按行输出 NDJSON 结果及一条摘要。`--max-findings`(默认 1000,上限 100000)限制单次内存;截断时用 `resumeAfter.filesId` 作为下次的 `--after-files-id`。摘要计数在扫描过程中增量累计。 ```sh export MONSQLIZE_RECOVERY_MONGODB_URI='<从密钥管理系统读取的 MongoDB URI>' @@ -59,6 +59,7 @@ node scripts/maintenance/gridfs-recover.cjs \ | direct/uploading | 验证 chunks 连续性、每块长度和总长度;只有 owner 记录了 upload() 写入前验证过的 expected SHA 时才强制校验摘要,否则将 metadata 不一致记为审计 warning | uploadStream() 与任意 metadata.sha256 都是不可信声明;绝不删除可见文件 chunks | | delete/deleting | 继续删除 files 和 chunks,之后才释放同 ID 的所有权 | 删除未完成时禁止替换上传 | | cleanup/cleaning | 可见文件保留 chunks,否则清理孤儿 chunks | 先查 files | +| resumable/initializing | 保留可见文件的分片,或清理不可见的残留分片,再释放初始化占位 | 文件不可见时,恢复完成后可重试 `beginResumableUpload` | | rename/renaming | 先核验可见文件及完整 chunks,缺失或损坏时保留锁;运维核查文件名 | 工具不猜测目标或重新执行 rename | | pending + activeAppend | 根据 chunks 的连续前缀恢复 nextChunk,清除中断令牌,重新设置七天闲置过期 | 保留已写 chunks,后续需验证重传 | | finalizing,文件不可见 | 退回 pending,正常 finalize 重新校验 | 不删已有 chunks | @@ -71,4 +72,4 @@ Direct owner 会保存摘要来源:`upload()` 的 `options.sha256` 在写入 ## 失败和验收边界 -恢复脚本位于源码 `scripts/maintenance/gridfs-recover.cjs`,不是对外公开 API。没有绝对可靠的在线“僵尸 Writer 自动判断”,因此不能靠超时解锁。必须以确定性测试覆盖异常中断后状态、错误令牌拒绝、错误时间戳拒绝、可见文件 chunks 绝不误删、finalize 重验 SHA、abort/delete 幂等续接、审计结果及两版 MongoDB。验证结果未全绿前不能把 PR #12 标记 Ready 或合入 main。 \ No newline at end of file +恢复脚本位于源码 `scripts/maintenance/gridfs-recover.cjs`,不是对外公开 API。没有绝对可靠的在线“僵尸 Writer 自动判断”,因此不能靠超时解锁。必须以确定性测试覆盖异常中断后状态、错误令牌拒绝、错误时间戳拒绝、可见文件 chunks 绝不误删、finalize 重验 SHA、abort/delete 幂等续接、审计结果及两版 MongoDB。验证结果未全绿前不能把 PR #12 标记 Ready 或合入 main。 diff --git a/docs/zh/gridfs.md b/docs/zh/gridfs.md index 14ebc36..34fa743 100644 --- a/docs/zh/gridfs.md +++ b/docs/zh/gridfs.md @@ -75,7 +75,7 @@ const info = await bucket.finalizeResumableUpload(session.uploadId); await bucket.abortResumableUpload(session.uploadId); ``` -`beginResumableUpload` 必须提供 `length` 和 `sha256`。`files` 或 `uploads` 中已经存在该 id 时抛出 `GRIDFS_FILE_EXISTS`。预检查不是锁,会话插入才是原子占位。插入之后会再次检查 `files`。若在这个窗口里文件已经可见,会尽量删除刚插入的 pending 会话,**绝不会**删除可见文件的 chunks,并抛出 `GRIDFS_FILE_EXISTS`。 +`beginResumableUpload` 必须提供 `length` 和 `sha256`。`files` 或 `uploads` 已存在该 id 时抛出 `GRIDFS_FILE_EXISTS`。插入会以 `initializing` 状态独占该 id,且没有过期时间。再次检查 `files` 并清理旧分片后,会话才变为 `pending`。初始化期间,针对同一 id 的其他写入会被阻止。若复查时文件已可见,会保留其分片并抛出 `GRIDFS_FILE_EXISTS`。初始化中断后,运维人员可在确认写入进程已停止后执行恢复。 分片长度必须符合会话: @@ -94,7 +94,7 @@ await bucket.abortResumableUpload(session.uploadId); `finalizeResumableUpload` 要求从 0 开始的每个预期序号恰好对应一个分片,总长度等于会话长度,且 sha256 等于会话摘要。然后插入 files 文档、写入 `metadata.sha256`,并删除会话。缺少的分片序号抛出 `GRIDFS_UPLOAD_INCOMPLETE`,`details[0].missingChunks` 列出缺口。序号无效、重复或越界,以及摘要或分片长度不符,均抛出 `GRIDFS_INTEGRITY_MISMATCH`。这些失败都不会插入 files 文档。 -会话仍在时,重试 `finalizeResumableUpload` 是幂等的:若同 id 的文件已可见,库会重新哈希**实际存储的 chunks**,只有摘要和长度都与会话一致时才返回该文件并删除会话。sha256 或长度不同的文件仍抛 `GRIDFS_FILE_EXISTS`。会话已不存在时(上一次 finalize 已完整成功、已 abort 或 TTL 过期),没有可比对的摘要,重试抛 `GRIDFS_FILE_NOT_FOUND`。调用方必须自己持久化 `uploadId`、`length` 和 `sha256`。会话消失后,先用 `getFile(uploadId)` 看文件是否可见,再对**实际字节**重新哈希(例如 `download(uploadId)` 或遍历 `.chunks`),与自己保存的长度和摘要比对。不要只信任 `metadata.sha256`:`uploadStream()` 和调用方写入的 `metadata.sha256` 可以是未校验的声明。本包不为此再提供新的公开校验 API。 +会话仍在时,重试 `finalizeResumableUpload` 是幂等的:若同 id 的文件已可见,库会重新哈希**实际存储的 chunks**,只有摘要、文件长度和分片大小都与会话一致时才返回该文件并删除会话。sha256 或长度不一致时抛出 `GRIDFS_FILE_EXISTS`。分片大小不一致时抛出 `GRIDFS_INTEGRITY_MISMATCH`,并保留已有的 `finalizing` 会话。会话已不存在时(上一次 finalize 已完整成功、已 abort 或 TTL 过期),没有可比对的摘要,重试抛 `GRIDFS_FILE_NOT_FOUND`。调用方必须自己持久化 `uploadId`、`length` 和 `sha256`。会话消失后,先用 `getFile(uploadId)` 看文件是否可见,再对**实际字节**重新哈希(例如 `download(uploadId)` 或遍历 `.chunks`),与自己保存的长度和摘要比对。不要只信任 `metadata.sha256`:`uploadStream()` 和调用方写入的 `metadata.sha256` 可以是未校验的声明。本包不为此再提供新的公开校验 API。 `finalizeResumableUpload` 在已经占有 `finalizing` 之后,若因 `GRIDFS_FILE_EXISTS` 或 `GRIDFS_INTEGRITY_MISMATCH` 失败,会把会话 CAS 回 `pending`,以便通过公开 API 执行 `abortResumableUpload`(然后再删除冲突的可见文件)。files 插入过程中含义不明的驱动错误仍会保留 `finalizing`,需显式恢复。 diff --git a/scripts/maintenance/gridfs-inspect-orphans.cjs b/scripts/maintenance/gridfs-inspect-orphans.cjs index 95c3f87..aa7099c 100644 --- a/scripts/maintenance/gridfs-inspect-orphans.cjs +++ b/scripts/maintenance/gridfs-inspect-orphans.cjs @@ -2,7 +2,7 @@ /** * Operator-only, inspect-only reporter for GridFS chunk files_id groups. - * Walks .chunks via the existing { files_id, n } index in range pages. + * Walks .chunks via the existing { files_id, n } index in bounded batches. * Each files_id is classified only after a unified re-check of files and uploads: * - visible_file: a files document exists * - owned: no files document, but an uploads owner/session exists @@ -33,11 +33,15 @@ function fail(message) { async function classifyFilesId(db, bucket, group) { const files = db.collection(bucket + '.files'); const uploads = db.collection(bucket + '.uploads'); - const file = await files.findOne({ _id: group.id }, { projection: { _id: 1 } }); + const fileFilter = { _id: group.id }; + const fileProjection = { projection: { _id: 1 } }; + let file = await files.findOne(fileFilter, fileProjection); const owner = await uploads.findOne( { _id: group.id }, { projection: { _id: 1, kind: 1, status: 1 } }, ); + // A successful upload publishes the file before it releases its owner. + if (!file && !owner) file = await files.findOne(fileFilter, fileProjection); let classification; if (file) classification = 'visible_file'; else if (owner) classification = 'owned'; @@ -79,17 +83,14 @@ async function inspectGridFSOrphans(input) { const afterId = parseAfter(input.after || input.resumeAfter); const chunks = db.collection(bucket + '.chunks'); const findings = collectFindings ? [] : undefined; - let lastId = afterId; - let lastN = -1; let current = null; - let pages = 0; + let rows = 0; let filesIdsScanned = 0; let visibleFiles = 0; let owned = 0; let candidateOrphans = 0; let truncated = false; let resumeAfter = null; - let lastClassifiedId = afterId; const flush = async (group) => { const finding = await classifyFilesId(db, bucket, group); @@ -99,57 +100,30 @@ async function inspectGridFSOrphans(input) { else candidateOrphans += 1; if (typeof onFinding === 'function') onFinding(finding); if (findings) findings.push(finding); - lastClassifiedId = group.id; - if (filesIdsScanned >= maxFindings) { - const more = await chunks.find({ files_id: { $gt: group.id } }).project({ _id: 1 }).limit(1).next(); - if (more) { - truncated = true; - resumeAfter = { - filesId: group.id instanceof ObjectId ? group.id.toHexString() : String(group.id), - }; - } - } + return finding.filesId; }; - for (;;) { - const filter = lastId == null - ? {} - : lastN < 0 - ? { files_id: { $gt: lastId } } - : { - $or: [ - { files_id: { $gt: lastId } }, - { files_id: lastId, n: { $gt: lastN } }, - ], - }; - const page = await chunks.find(filter) - .sort({ files_id: 1, n: 1 }) - .project({ files_id: 1, n: 1 }) - .limit(pageSize) - .toArray(); - pages += 1; - if (page.length === 0) break; - for (const doc of page) { - const filesId = doc.files_id; - if (!current || !sameId(current.id, filesId)) { - if (current) { - await flush(current); - if (truncated) { - current = null; - break; - } + const cursor = chunks.find(afterId ? { files_id: { $gt: afterId } } : {}) + .sort({ files_id: 1, n: 1 }) + .project({ _id: 0, files_id: 1, n: 1 }) + .batchSize(pageSize); + for await (const doc of cursor) { + rows += 1; + if (!current || !sameId(current.id, doc.files_id)) { + if (current) { + const filesId = await flush(current); + if (filesIdsScanned === maxFindings) { + truncated = true; + resumeAfter = { filesId }; + break; } - current = { id: filesId, minN: doc.n, maxN: doc.n, count: 1 }; - } else { - current.count += 1; - if (doc.n < current.minN) current.minN = doc.n; - if (doc.n > current.maxN) current.maxN = doc.n; } - lastId = filesId; - lastN = doc.n; + current = { id: doc.files_id, minN: doc.n, maxN: doc.n, count: 1 }; + } else { + current.count += 1; + if (doc.n < current.minN) current.minN = doc.n; + if (doc.n > current.maxN) current.maxN = doc.n; } - if (truncated) break; - if (page.length < pageSize) break; } if (current && !truncated) await flush(current); @@ -157,7 +131,7 @@ async function inspectGridFSOrphans(input) { bucket, pageSize, maxFindings, - pages, + pages: Math.max(1, Math.ceil(rows / pageSize)), filesIdsScanned, visibleFiles, owned, diff --git a/scripts/maintenance/gridfs-recover.cjs b/scripts/maintenance/gridfs-recover.cjs index 434eec0..ab65325 100644 --- a/scripts/maintenance/gridfs-recover.cjs +++ b/scripts/maintenance/gridfs-recover.cjs @@ -18,6 +18,7 @@ const RECOVERY_ORIGIN_KINDS = new Map([ ['deleting', 'delete'], ['renaming', 'rename'], ['cleaning', 'cleanup'], + ['initializing', 'resumable'], ['activeAppend', 'resumable'], ['finalizing', 'resumable'], ['aborting', 'resumable'], @@ -119,6 +120,7 @@ function classify(doc) { else if (doc.kind === 'delete' && doc.status === 'deleting') state = 'deleting'; else if (doc.kind === 'rename' && doc.status === 'renaming') state = 'renaming'; else if (doc.kind === 'cleanup' && doc.status === 'cleaning') state = 'cleaning'; + else if (doc.kind === 'resumable' && doc.status === 'initializing') state = 'initializing'; else if ((!doc.kind || doc.kind === 'resumable') && doc.status === 'pending' && doc.activeAppend) state = 'activeAppend'; else if ((!doc.kind || doc.kind === 'resumable') && @@ -129,7 +131,8 @@ function classify(doc) { const requiredKind = RECOVERY_ORIGIN_KINDS.get(state); if (!requiredKind || (doc.kind !== requiredKind && !(requiredKind === 'resumable' && !doc.kind)) || - (state === 'activeAppend' && !doc.activeAppend?.token)) { + (state === 'activeAppend' && !doc.activeAppend?.token) || + (state === 'initializing' && (doc.kind !== 'resumable' || !doc.ownerToken))) { fail('Inconsistent or unsupported owner origin; preserve ownership'); } return state; @@ -182,6 +185,7 @@ async function recoverGridFSOwnership(input) { deleting: 'complete-file-and-chunk-deletion', renaming: 'release-after-inspecting-rename-outcome', cleaning: 'remove-invisible-orphan-chunks', + initializing: 'remove-invisible-orphan-chunks-and-release-initialization', activeAppend: 'restore-pending-and-reconcile-prefix', finalizing: visible ? 'verify-visible-bytes-and-release-session' : 'restore-pending-for-retry', aborting: visible ? 'BLOCK-visible-file-conflict' : 'finish-invisible-chunk-removal', @@ -286,7 +290,7 @@ async function recoverGridFSOwnership(input) { if (sourceStatus === 'deleting') { await files.deleteOne({ _id: id }); await chunks.deleteMany({ files_id: id }); - } else if ((sourceStatus === 'uploading' || sourceStatus === 'cleaning' || + } else if ((sourceStatus === 'uploading' || sourceStatus === 'cleaning' || sourceStatus === 'initializing' || sourceStatus === 'aborting') && !visibleNow) { await chunks.deleteMany({ files_id: id }); } diff --git a/src/capabilities/gridfs/resumable.ts b/src/capabilities/gridfs/resumable.ts index dfa0de8..3c3e60c 100644 --- a/src/capabilities/gridfs/resumable.ts +++ b/src/capabilities/gridfs/resumable.ts @@ -20,7 +20,7 @@ import { toNodeBuffer, withContentType, } from './validate'; -import { consumeReadableChunks } from './stream-chunks'; +import { consumeReadableChunks, watchReadable } from './stream-chunks'; export async function beginResumableUpload(ctx: GridFSContext, filename: string, opts: BeginResumableUploadOptions): Promise { assertFilename(filename, 'beginResumableUpload'); @@ -47,38 +47,40 @@ export async function beginResumableUpload(ctx: GridFSContext, filename: string, } const now = new Date(); + const ownerToken = randomUUID(); + const initializing = { _id: uploadId, kind: 'resumable', status: 'initializing', ownerToken }; try { await uploads.insertOne({ - _id: uploadId, + ...initializing, filename, length: opts.length, sha256: opts.sha256, chunkSizeBytes, ...(metadata ? { metadata } : {}), ...(opts.contentType !== undefined ? { contentType: opts.contentType } : {}), - kind: 'resumable', - status: 'pending', nextChunk: 0, createdAt: now, updatedAt: now, - expiresAt: new Date(now.getTime() + 604800 * 1000), }); } catch (error) { throw mapDriverError(error, 'beginResumableUpload', ctx, { uploadId, filename }); } - // The insert is the atomic id claim. A concurrent upload can still make a - // files document visible after the prechecks and even after this insert. - // Re-check files and fail closed: never delete chunks of a visible file. + // Only publish a pending session after its orphan cleanup has settled. + // Initializing has no TTL and excludes append, finalize, abort and ID reuse. try { if (await files.findOne({ _id: uploadId }, { projection: { _id: 1 } })) { try { - await uploads.deleteOne({ _id: uploadId, kind: 'resumable', status: 'pending' }); + await uploads.deleteOne(initializing); } catch { /* Best-effort session cleanup; chunks of the visible file stay. */ } throw fileExists('beginResumableUpload', ctx, { id: uploadId, filename, sha256: opts.sha256 }); } - // Chunks can outlive an expired session (the TTL only removes the session document). - // This call now owns the id and no file is visible, so leftovers would only break the new upload. await ctx.db.collection(chunksCollectionName(ctx.bucketName)).deleteMany({ files_id: uploadId }); + const updatedAt = new Date(); + const ready = await uploads.updateOne(initializing, { + $set: { status: 'pending', updatedAt, expiresAt: new Date(updatedAt.getTime() + 604800 * 1000) }, + $unset: { ownerToken: '' }, + }); + if (!ready.matchedCount) throw uploadBusy('beginResumableUpload', ctx, uploadId); } catch (error) { throw mapDriverError(error, 'beginResumableUpload', ctx, { uploadId, filename }); } @@ -93,15 +95,29 @@ export async function appendChunks( ): Promise { assertObjectId(uploadId, 'appendChunks', 'uploadId'); assertNonNegativeInteger(fromChunk, 'appendChunks', 'fromChunk'); - const isStream = isReadableStream(data); - if (!isStream && !Buffer.isBuffer(data) && !(data instanceof Uint8Array)) { - throw invalidArgument('appendChunks', 'data', 'data must be a Buffer, Uint8Array, or Readable'); + if (isReadableStream(data)) { + const stream = watchReadable(data); + try { + if (stream.error) throw stream.error; + return await appendToSession(ctx, uploadId, fromChunk, stream); + } finally { + stream.release(); + } } - let payload: Buffer | undefined; - if (!isStream) { - payload = toNodeBuffer(data as Buffer | Uint8Array); - if (payload.length === 0) throw badChunkSize(); + if (!Buffer.isBuffer(data) && !(data instanceof Uint8Array)) { + throw invalidArgument('appendChunks', 'data', 'data must be a Buffer, Uint8Array, or Readable'); } + const payload = toNodeBuffer(data); + if (payload.length === 0) throw badChunkSize(); + return appendToSession(ctx, uploadId, fromChunk, payload); +} + +async function appendToSession( + ctx: GridFSContext, + uploadId: ObjectId, + fromChunk: number, + data: Buffer | ReturnType, +): Promise { const uploads = ctx.db.collection(uploadsCollectionName(ctx.bucketName)); const token = randomUUID(); // A durable single-append claim excludes finalize, abort, and concurrent @@ -122,6 +138,7 @@ export async function appendChunks( throw uploadBusy('appendChunks', ctx, uploadId); } try { + if (!Buffer.isBuffer(data) && data.error) throw data.error; const session = await requireSession(ctx, uploadId, 'appendChunks'); const nextChunk = await contiguousNext(ctx, uploadId, session.nextChunk); if (fromChunk > nextChunk) { @@ -138,23 +155,18 @@ export async function appendChunks( await writeChunk(ctx, uploadId, slice.index, slice.bytes, nextChunk); }; let bytesAccepted: number; - if (isStream) { - const streamed = await consumeReadableChunks( - data as Readable, - fromChunk, - session.length, - session.chunkSizeBytes, - writeSlice, - ); - bytesAccepted = streamed.bytesAccepted; - } else { - const slices = planChunkSlices(payload as Buffer, fromChunk, session.length, session.chunkSizeBytes); + if (Buffer.isBuffer(data)) { + const slices = planChunkSlices(data, fromChunk, session.length, session.chunkSizeBytes); const lastIndex = slices[slices.length - 1].index; if (lastIndex >= nextChunk && fromChunk !== nextChunk) { throw resumeMismatch('appendChunks', ctx.bucketName, { uploadId, fromChunk, expectedNextChunk: nextChunk }); } for (const slice of slices) await writeSlice(slice); - bytesAccepted = (payload as Buffer).length; + bytesAccepted = data.length; + } else { + const streamed = await consumeReadableChunks(data.source, fromChunk, session.length, + session.chunkSizeBytes, writeSlice, undefined, data); + bytesAccepted = streamed.bytesAccepted; } const advanced = await contiguousNext(ctx, uploadId, nextChunk); const completed = await uploads.updateOne( @@ -368,6 +380,11 @@ async function committedFile(ctx: GridFSContext, uploadId: ObjectId, session: Gr if (info.metadata?.sha256 !== session.sha256 || info.length !== session.length) { throw fileExists('finalizeResumableUpload', ctx, { id: uploadId, filename: session.filename, sha256: session.sha256 }); } + if (info.chunkSize !== session.chunkSizeBytes) { + throw integrityMismatch('finalizeResumableUpload', ctx.bucketName, { + uploadId, expected: session.chunkSizeBytes, actual: info.chunkSize, + }); + } // Metadata-only SHA can be forged by uploadStream(). Never treat it as // proof of a previous resumable commit without verifying actual chunks. const hash = createHash('sha256'); diff --git a/src/capabilities/gridfs/stream-chunks.ts b/src/capabilities/gridfs/stream-chunks.ts index dfe2916..c5c764f 100644 --- a/src/capabilities/gridfs/stream-chunks.ts +++ b/src/capabilities/gridfs/stream-chunks.ts @@ -19,6 +19,38 @@ function isPrematureClose(source: Readable, ended: boolean): boolean { return source.destroyed && !ended && !source.readableEnded; } +/** Capture errors before the append claim and keep handling errors from async destruction. */ +export function watchReadable(source: Readable) { + let error: Error | undefined; + let released = false; + const detach = (): void => { + source.removeListener('error', onError); + source.removeListener('close', detach); + }; + const onError = (cause: Error): void => { + error ??= cause; + if (released) detach(); + }; + source.on('error', onError); + return { + source, + get error() { return error ?? source.errored; }, + release(): void { + released = true; + if (source.readableEnded && !source.destroyed) { + detach(); + return; + } + if (!source.closed) source.once('close', detach); + if (!source.destroyed) source.destroy(); + // A synchronous _destroy can queue an error even after closed is true. + setImmediate(() => { + if (source.closed) detach(); + }); + }, + }; +} + /** * Consume a Readable into GridFS-sized slices without buffering the whole payload. * Retention is limited to a residual shorter than the next expected slice plus the @@ -36,7 +68,9 @@ export async function consumeReadableChunks( chunkSizeBytes: number, onChunk: (slice: StreamedChunk) => Promise, onBufferedBytes?: (bytes: number) => void, + watched?: ReturnType, ): Promise<{ bytesAccepted: number }> { + const stream = watched ?? watchReadable(source); const total = chunkCount(length, chunkSizeBytes); let residual = Buffer.alloc(0); let index = fromChunk; @@ -63,7 +97,7 @@ export async function consumeReadableChunks( let offset = 0; report(incoming.length); while (offset < incoming.length) { - if (aborted || isPrematureClose(source, ended)) return; + if (aborted || stream.error || isPrematureClose(source, ended)) return; if (index >= total) badChunkSize(); const want = chunkLengthAt(index, length, chunkSizeBytes); const have = residual.length + (incoming.length - offset); @@ -92,6 +126,7 @@ export async function consumeReadableChunks( await new Promise((resolve, reject) => { const detach = (): void => { + clearInterval(checkDestroyed); source.removeListener('readable', onReadable); source.removeListener('end', onEnd); source.removeListener('error', onError); @@ -132,10 +167,11 @@ export async function consumeReadableChunks( busy = true; try { let incoming: Buffer | string | null; - while (!aborted && (incoming = source.read() as Buffer | string | null) !== null) { + while (!aborted && !stream.error && !isPrematureClose(source, ended) && + (incoming = source.read() as Buffer | string | null) !== null) { await takeFrom(Buffer.isBuffer(incoming) ? incoming : Buffer.from(incoming)); } - if (queuedError) throw queuedError; + if (queuedError || stream.error) throw queuedError ?? stream.error; if (isPrematureClose(source, ended)) throw streamClosedError(); if (ended || source.readableEnded) { ended = true; @@ -152,8 +188,8 @@ export async function consumeReadableChunks( pending = true; return; } - if (queuedError) { - finish(queuedError); + if (queuedError || stream.error) { + finish(queuedError ?? stream.error); return; } if (isPrematureClose(source, ended)) { @@ -162,7 +198,7 @@ export async function consumeReadableChunks( } pump().then(() => { if (settled) return; - if (pending || queuedError || isPrematureClose(source, ended)) { + if (pending || queuedError || stream.error || isPrematureClose(source, ended)) { pending = false; run(); return; @@ -175,17 +211,18 @@ export async function consumeReadableChunks( source.on('end', onEnd); source.on('error', onError); source.on('close', onClose); - if (isPrematureClose(source, ended)) { - finish(streamClosedError()); - return; - } + // emitClose:false suppresses the notification even when an idle source is destroyed. + const checkDestroyed = setInterval(() => { + if (isPrematureClose(source, ended)) abort(stream.error ?? streamClosedError()); + }, 50); + checkDestroyed.unref(); run(); if (!ended && source.readableEnded) { ended = true; run(); } }).finally(() => { - if (!source.destroyed && !source.readableEnded) source.destroy(); + if (!watched) stream.release(); }); if (bytesAccepted === 0) badChunkSize(); diff --git a/src/capabilities/gridfs/types.ts b/src/capabilities/gridfs/types.ts index baaf27f..6a3b00d 100644 --- a/src/capabilities/gridfs/types.ts +++ b/src/capabilities/gridfs/types.ts @@ -85,7 +85,7 @@ export interface GridFSUploadSession { metadata?: Record; contentType?: string; kind?: 'resumable'; - status: 'pending' | 'finalizing' | 'aborting' | 'finalized' | 'aborted'; + status: 'initializing' | 'pending' | 'finalizing' | 'aborting' | 'finalized' | 'aborted'; activeAppend?: { token: string; startedAt: Date }; expiresAt?: Date; nextChunk: number; diff --git a/test/integration/gridfs/gridfs-inspect-orphans.test.ts b/test/integration/gridfs/gridfs-inspect-orphans.test.ts index 6b38317..4cf2956 100644 --- a/test/integration/gridfs/gridfs-inspect-orphans.test.ts +++ b/test/integration/gridfs/gridfs-inspect-orphans.test.ts @@ -94,11 +94,13 @@ describe('GridFS inspect-only orphan reporter', () => { }))); const originalFindOne = Collection.prototype.findOne; + let published = false; (Collection.prototype as any).findOne = async function (...args: unknown[]) { // After the first files_id is classified, make the last id visible before its re-check. if (this.collectionName === 'b.files') { const filter = args[0] as { _id?: ObjectId }; - if (filter?._id && filter._id.equals(ids[0])) { + if (!published && filter?._id && filter._id.equals(ids[0])) { + published = true; await db.collection('b.files').insertOne({ _id: ids[2], filename: 'late.bin', length: 4, chunkSize: 4, uploadDate: new Date(), }); @@ -118,6 +120,52 @@ describe('GridFS inspect-only orphan reporter', () => { }); }); + it('recognizes a file published between the files and owner lookups', async () => { + await withDb(async (db) => { + const id = new ObjectId(); + await db.collection('b.chunks').insertOne({ files_id: id, n: 0, data: Buffer.from('DATA') }); + await db.collection('b.uploads').insertOne({ _id: id, status: 'uploading' }); + const original = Collection.prototype.findOne; + Collection.prototype.findOne = async function (this: Collection, ...args: unknown[]) { + const filter = args[0] as { _id?: ObjectId } | undefined; + if (this.collectionName === 'b.uploads' && id.equals(filter?._id)) { + await db.collection('b.files').insertOne({ + _id: id, filename: 'published.bin', length: 4, chunkSize: 4, uploadDate: new Date(), + }); + await db.collection('b.uploads').deleteOne({ _id: id }); + } + return Reflect.apply(original, this, args); + } as typeof original; + try { + const report = await inspectGridFSOrphans({ db, bucket: 'b' }); + assert.equal(report.findings[0].classification, 'visible_file'); + assert.equal(report.candidateOrphans, 0); + } finally { + Collection.prototype.findOne = original; + } + }); + }); + + it('counts a group split across batches starting with a negative chunk index', async () => { + await withDb(async (db) => { + const id = new ObjectId(); + const nextId = new ObjectId(); + await db.collection('b.chunks').insertMany([ + ...[-1, 0, 1].map((n) => ({ files_id: id, n, data: Buffer.from('DATA') })), + { files_id: nextId, n: 0, data: Buffer.from('NEXT') }, + ]); + const first = await inspectGridFSOrphans({ db, bucket: 'b', pageSize: 1, maxFindings: 1 }); + assert.deepEqual(first.findings, [{ + filesId: id.toHexString(), chunkCount: 3, minN: -1, maxN: 1, classification: 'candidate_orphan', + }]); + assert.equal(first.truncated, true); + const resumed = await inspectGridFSOrphans({ db, bucket: 'b', pageSize: 1, maxFindings: 1, after: first.resumeAfter }); + assert.equal(resumed.truncated, false); + assert.equal(resumed.findings[0].filesId, nextId.toHexString()); + assert.equal(await db.collection('b.chunks').countDocuments(), 4); + }); + }); + it('caps findings, streams results, and resumes a large-cardinality scan without deleting', async () => { await withDb(async (db) => { const total = 250; @@ -151,11 +199,6 @@ describe('GridFS inspect-only orphan reporter', () => { assert.equal(rest.truncated, false); assert.equal(rest.resumeAfter, null); assert.equal(first.filesIdsScanned + second.filesIdsScanned + rest.filesIdsScanned, total); - const seen = new Set([ - ...first.findings.map((row: { filesId: string }) => row.filesId), - ...rest.findings.map((row: { filesId: string }) => row.filesId), - ...streamed, - ]); // streamed includes first+second pages; rest.findings is the remainder const all = new Set([ ...first.findings.map((row: { filesId: string }) => row.filesId), diff --git a/test/integration/gridfs/gridfs-remaining-issues.test.ts b/test/integration/gridfs/gridfs-remaining-issues.test.ts index e4d98f8..9e6a75d 100644 --- a/test/integration/gridfs/gridfs-remaining-issues.test.ts +++ b/test/integration/gridfs/gridfs-remaining-issues.test.ts @@ -15,6 +15,12 @@ function delayed(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function gate(): { wait: Promise; open: () => void } { + let open = (): void => {}; + const wait = new Promise((resolve) => { open = resolve; }); + return { wait, open }; +} + describe('GridFS remaining-issue regressions (begin race, finalize rollback, streaming append)', () => { const bootstrap = createMemoryServerBootstrap(); let uri = ''; @@ -78,6 +84,47 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str }); }); + it('excludes writes and reuse of an explicit ID until begin cleanup completes', { timeout: 10_000 }, async () => { + await withRuntime('gridfs_initializing', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const id = new ObjectId(); + const payload = Buffer.from('GOOD'); + await db.collection('b.chunks').insertOne({ files_id: id, n: 0, data: Buffer.from('OLD!') }); + const entered = gate(); + const proceed = gate(); + const original = Collection.prototype.deleteMany; + let paused = false; + Collection.prototype.deleteMany = async function (this: Collection, filter, options) { + if (!paused && this.collectionName === 'b.chunks' && id.equals(filter?.files_id)) { + paused = true; + entered.open(); + await proceed.wait; + } + return original.call(this, filter, options); + }; + const starting = bucket.beginResumableUpload('starting.bin', { id, length: 4, sha256: sha(payload) }); + try { + await entered.wait; + const busy = { code: 'GRIDFS_UPLOAD_BUSY' }; + await assert.rejects(bucket.appendChunks(id, 0, payload), busy); + await assert.rejects(bucket.finalizeResumableUpload(id), busy); + await assert.rejects(bucket.abortResumableUpload(id), busy); + await assert.rejects(bucket.upload('replacement.bin', payload, { id }), busy); + const owner = await db.collection('b.uploads').findOne({ _id: id }); + assert.equal(owner?.status, 'initializing'); + assert.equal(owner?.expiresAt, undefined); + } finally { + proceed.open(); + Collection.prototype.deleteMany = original; + await starting; + } + assert.equal(await db.collection('b.chunks').countDocuments({ files_id: id }), 0); + await bucket.appendChunks(id, 0, payload); + await bucket.finalizeResumableUpload(id); + assert.deepEqual(await bucket.download(id), payload); + }); + }); + it('finalize FILE_EXISTS rolls back to pending so public abort works and visible chunks stay', { timeout: 30_000 }, async () => { await withRuntime('gridfs_finalize_rollback', async (runtime, db) => { const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); @@ -106,6 +153,30 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str }); }); + it('retains the finalizing session when the visible file declares a different chunk size', async () => { + await withRuntime('gridfs_finalize_wrong_size', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const payload = Buffer.from('aaaabbbbcccc'); + const { uploadId } = await bucket.beginResumableUpload('retry.bin', { length: 12, sha256: sha(payload) }); + await bucket.appendChunks(uploadId, 0, payload); + const uploads = db.collection('b.uploads'); + const files = db.collection('b.files'); + await uploads.updateOne({ _id: uploadId }, { $set: { status: 'finalizing' }, $unset: { expiresAt: '' } }); + await files.insertOne({ + _id: uploadId, filename: 'retry.bin', length: 12, chunkSize: 8, + uploadDate: new Date(), metadata: { sha256: sha(payload) }, + }); + const owner = await uploads.findOne({ _id: uploadId }); + await assert.rejects(bucket.finalizeResumableUpload(uploadId), { code: 'GRIDFS_INTEGRITY_MISMATCH' }); + assert.deepEqual(await uploads.findOne({ _id: uploadId }), owner); + assert.equal(await db.collection('b.chunks').countDocuments({ files_id: uploadId }), 3); + + await files.updateOne({ _id: uploadId }, { $set: { chunkSize: 4 } }); + assert.equal((await bucket.finalizeResumableUpload(uploadId)).chunkSize, 4); + assert.deepEqual(await bucket.download(uploadId), payload); + }); + }); + it('rejects malformed or extra chunk indexes even when the expected bytes are present', async () => { await withRuntime('gridfs_finalize_invalid_index', async (runtime, db) => { const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); @@ -231,6 +302,58 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str }); }); + it('handles a source failure while acquiring the append claim', { timeout: 10_000 }, async () => { + await withRuntime('gridfs_append_claim_error', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const payload = Buffer.from('GOOD'); + const { uploadId } = await bucket.beginResumableUpload('claim.bin', { length: 4, sha256: sha(payload) }); + const claiming = gate(); + const proceed = gate(); + const original = Collection.prototype.updateOne; + Collection.prototype.updateOne = async function (this: Collection, filter, update, options) { + if (this.collectionName === 'b.uploads' && !Array.isArray(update) && update.$set?.activeAppend) { + claiming.open(); + await proceed.wait; + } + return original.call(this, filter, update, options); + }; + const source = new Readable({ read() {} }); + const error = new Error('source failed before claim'); + const rejected = assert.rejects(bucket.appendChunks(uploadId, 0, source), (actual) => actual === error); + try { + await claiming.wait; + source.destroy(error); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + proceed.open(); + Collection.prototype.updateOne = original; + await rejected; + } + assert.equal((await db.collection('b.uploads').findOne({ _id: uploadId }))?.activeAppend, undefined); + assert.equal(await db.collection('b.chunks').countDocuments({ files_id: uploadId }), 0); + assert.equal((await bucket.appendChunks(uploadId, 0, payload)).nextChunk, 1); + }); + }); + + it('releases the append claim when an idle source is destroyed without a close event', { timeout: 10_000 }, async () => { + await withRuntime('gridfs_append_silent_close', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const payload = Buffer.from('GOOD'); + const { uploadId } = await bucket.beginResumableUpload('idle.bin', { length: 4, sha256: sha(payload) }); + const reading = gate(); + const source = new Readable({ emitClose: false, read() { reading.open(); } }); + const rejected = assert.rejects(bucket.appendChunks(uploadId, 0, source), { code: 'INVALID_ARGUMENT' }); + await reading.wait; + await new Promise((resolve) => setImmediate(resolve)); + source.destroy(); + await rejected; + const owner = await db.collection('b.uploads').findOne({ _id: uploadId }); + assert.equal(owner?.activeAppend, undefined); + assert.ok(owner?.expiresAt instanceof Date); + await bucket.abortResumableUpload(uploadId); + }); + }); + it('resumes after a streaming append writes one full chunk then rejects a trailing partial', { timeout: 30_000 }, async () => { await withRuntime('gridfs_stream_resume_partial', async (runtime, db) => { const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); diff --git a/test/integration/gridfs/gridfs-stale-recovery.test.ts b/test/integration/gridfs/gridfs-stale-recovery.test.ts index 6a22556..e247523 100644 --- a/test/integration/gridfs/gridfs-stale-recovery.test.ts +++ b/test/integration/gridfs/gridfs-stale-recovery.test.ts @@ -261,6 +261,29 @@ describe('GridFS operator recovery: deterministic post-crash state and chunk saf }); }); + it('recovers interrupted initialization after quiescence without deleting a visible file', async () => { + await withRuntime(async (bucket, db) => { + const payload = Buffer.from('GOOD'); + const uploads = db.collection('b.uploads'); + const chunks = db.collection('b.chunks'); + for (const visible of [false, true]) { + const id = new ObjectId(); + if (visible) await bucket.upload('visible.bin', payload, { id }); + else await chunks.insertOne({ files_id: id, n: 0, data: payload }); + await uploads.insertOne({ _id: id, kind: 'resumable', status: 'initializing', + updatedAt: new Date(), ownerToken: 'initialization-token' }); + + await assert.rejects(recover(db, id, { quiesced: false }), /requires external writer quiescence/); + const receipt = await recover(db, id); + assert.equal(receipt.outcome, 'completed'); + assert.equal(await uploads.findOne({ _id: id }), null); + assert.equal(await chunks.countDocuments({ files_id: id }), visible ? 1 : 0); + if (visible) assert.deepEqual(await bucket.download(id), payload); + else await bucket.beginResumableUpload('retry.bin', { id, length: 4, sha256: digest(payload) }); + } + }); + }); + it('can resume a recovery crash using the persistent recovery token', async () => { await withRuntime(async (_, db) => { const id = new ObjectId(); diff --git a/test/unit/gridfs/stream-chunks.test.ts b/test/unit/gridfs/stream-chunks.test.ts index a14887f..57cd5a1 100644 --- a/test/unit/gridfs/stream-chunks.test.ts +++ b/test/unit/gridfs/stream-chunks.test.ts @@ -182,6 +182,32 @@ describe('consumeReadableChunks', () => { ); }); + it('handles an asynchronous destroy error after the append settles', async () => { + let pushed = false; + let finishDestroy = (): void => {}; + const failure = new Error('destroy failed'); + const source = new Readable({ + read() { + if (pushed) return; + pushed = true; + this.push(Buffer.from('aaaabbbb')); + }, + destroy(error, callback) { + finishDestroy = () => callback(error); + }, + }); + try { + await assert.rejects(settles(consumeReadableChunks(source, 0, 8, 4, async () => { + source.destroy(failure); + })), (actual) => actual === failure); + } finally { + finishDestroy(); + await new Promise((resolve) => setImmediate(resolve)); + } + assert.equal(source.listenerCount('error'), 0); + assert.equal(source.listenerCount('close'), 0); + }); + it('waits for the in-flight write and discards the remaining buffered chunks on destroy', async () => { let writeFinished = false; let pushed = false; From 216a8e55caec82e1e6cb2a04c569224f34d60339 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 09:58:45 +0800 Subject: [PATCH 07/12] fix(gridfs): preserve unread streams for append retries --- src/capabilities/gridfs/stream-chunks.ts | 7 +++-- .../gridfs/gridfs-remaining-issues.test.ts | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/capabilities/gridfs/stream-chunks.ts b/src/capabilities/gridfs/stream-chunks.ts index c5c764f..6b794bf 100644 --- a/src/capabilities/gridfs/stream-chunks.ts +++ b/src/capabilities/gridfs/stream-chunks.ts @@ -35,9 +35,10 @@ export function watchReadable(source: Readable) { return { source, get error() { return error ?? source.errored; }, - release(): void { + release(consumed = false): void { + if (released) return; released = true; - if (source.readableEnded && !source.destroyed) { + if (!source.destroyed && (!consumed || source.readableEnded)) { detach(); return; } @@ -222,7 +223,7 @@ export async function consumeReadableChunks( run(); } }).finally(() => { - if (!watched) stream.release(); + stream.release(true); }); if (bytesAccepted === 0) badChunkSize(); diff --git a/test/integration/gridfs/gridfs-remaining-issues.test.ts b/test/integration/gridfs/gridfs-remaining-issues.test.ts index 9e6a75d..045dad3 100644 --- a/test/integration/gridfs/gridfs-remaining-issues.test.ts +++ b/test/integration/gridfs/gridfs-remaining-issues.test.ts @@ -335,6 +335,37 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str }); }); + it('keeps an unread source reusable after pre-consumption rejections', async () => { + await withRuntime('gridfs_append_unread_retry', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const payload = Buffer.from('GOOD'); + const { uploadId } = await bucket.beginResumableUpload('retry.bin', { length: 4, sha256: sha(payload) }); + const uploads = db.collection('b.uploads'); + let reads = 0; + const source = new Readable({ + read() { + reads += 1; + this.push(payload); + this.push(null); + }, + }); + await assert.rejects(bucket.appendChunks(new ObjectId(), 0, source), { code: 'GRIDFS_FILE_NOT_FOUND' }); + await uploads.updateOne({ _id: uploadId }, { $set: { activeAppend: { token: 'other-writer' } } }); + await assert.rejects(bucket.appendChunks(uploadId, 0, source), { code: 'GRIDFS_UPLOAD_BUSY' }); + await uploads.updateOne({ _id: uploadId }, { $unset: { activeAppend: '' } }); + await assert.rejects(bucket.appendChunks(uploadId, 1, source), (error: unknown) => { + const mismatch = error as { code: string; details: { expectedNextChunk: number }[] }; + return mismatch.code === 'GRIDFS_RESUME_MISMATCH' && mismatch.details[0].expectedNextChunk === 0; + }); + assert.equal(reads, 0); + assert.equal(source.destroyed, false); + assert.equal(source.listenerCount('error'), 0); + assert.equal((await bucket.appendChunks(uploadId, 0, source)).nextChunk, 1); + await bucket.finalizeResumableUpload(uploadId); + assert.deepEqual(await bucket.download(uploadId), payload); + }); + }); + it('releases the append claim when an idle source is destroyed without a close event', { timeout: 10_000 }, async () => { await withRuntime('gridfs_append_silent_close', async (runtime, db) => { const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); From 0d76c764ea3a54fdb1f1ebcfe7036f963f7874cc Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 10:37:24 +0800 Subject: [PATCH 08/12] fix(gridfs): bind finalize ownership and bound stream cleanup --- docs/en/gridfs.md | 2 +- docs/zh/gridfs.md | 2 +- scripts/maintenance/gridfs-recover.cjs | 2 +- src/capabilities/gridfs/resumable.ts | 57 ++++---- src/capabilities/gridfs/stream-chunks.ts | 30 ++-- src/capabilities/gridfs/types.ts | 1 + .../gridfs/gridfs-remaining-issues.test.ts | 137 ++++++++++++++++++ test/unit/gridfs/gridfs.test.ts | 7 +- test/unit/gridfs/stream-chunks.test.ts | 24 +++ 9 files changed, 222 insertions(+), 40 deletions(-) diff --git a/docs/en/gridfs.md b/docs/en/gridfs.md index 9c91685..2c05799 100644 --- a/docs/en/gridfs.md +++ b/docs/en/gridfs.md @@ -81,7 +81,7 @@ Chunk bytes must follow the session size: - Every chunk except the last has length `chunkSizeBytes`. - The last chunk has length `length % chunkSizeBytes`, or `chunkSizeBytes` when the length divides evenly. -- A payload that does not split into those sizes throws `INVALID_ARGUMENT`. For a `Buffer` or `Uint8Array`, the whole payload is planned and checked before any chunk is inserted. For a `Readable`, complete chunks are written as they arrive; a trailing partial or oversize remainder is rejected during consumption, after any complete prefix chunks have been stored. A failed append throws and does not return `nextChunk`. Resume from the last successfully acknowledged position; if the stored prefix has moved, `GRIDFS_RESUME_MISMATCH` includes `expectedNextChunk`. Peak memory is the residual (shorter than the next expected chunk) plus the current incoming stream buffer, not the full payload. Use a modest `highWaterMark` for large files. +- A payload that does not split into those sizes throws `INVALID_ARGUMENT`. For a `Buffer` or `Uint8Array`, the whole payload is planned and checked before any chunk is inserted. For a `Readable`, complete chunks are written as they arrive; a trailing partial or oversize remainder is rejected during consumption, after any complete prefix chunks have been stored. A failed append throws and does not return `nextChunk`. Resume from the last successfully acknowledged position; if the stored prefix has moved, `GRIDFS_RESUME_MISMATCH` includes `expectedNextChunk`. A source error during automatic destruction after EOF also rejects the append. Memory use is bounded by the current stream buffer, the partial chunk, and a fixed-size batch of stored chunk indexes. Use a modest `highWaterMark` for large files. `appendChunks` is addressed by `fromChunk`: diff --git a/docs/zh/gridfs.md b/docs/zh/gridfs.md index 34fa743..c845685 100644 --- a/docs/zh/gridfs.md +++ b/docs/zh/gridfs.md @@ -81,7 +81,7 @@ await bucket.abortResumableUpload(session.uploadId); - 除最后一片外,每片长度都是 `chunkSizeBytes`。 - 最后一片长度是 `length % chunkSizeBytes`;能够整除时仍是 `chunkSizeBytes`。 -- 无法按这些长度切开的 payload 抛出 `INVALID_ARGUMENT`。对 `Buffer` / `Uint8Array`,整段 payload 会在插入任何分片之前完成规划与检查。对 `Readable`,完整分片会随读随写;末尾不足或超出的残余会在消费过程中被拒绝,此前已经写入的完整前缀分片会保留。失败的 append 会抛错,不会返回 `nextChunk`。应从上一次已成功确认的位置继续;若已写入前缀已前进,以 `GRIDFS_RESUME_MISMATCH.expectedNextChunk` 为准。峰值内存是“下一片所需长度以内的残余 + 当前流入的 stream 缓冲”,不会随整个 payload 线性增长。大文件应使用较小的 `highWaterMark`。 +- 无法按这些长度切开的 payload 抛出 `INVALID_ARGUMENT`。对 `Buffer` / `Uint8Array`,整段 payload 会在插入任何分片之前完成规划与检查。对 `Readable`,完整分片会随读随写;末尾不足或超出的残余会在消费过程中被拒绝,此前已经写入的完整前缀分片会保留。失败的 append 会抛错,不会返回 `nextChunk`。应从上一次已成功确认的位置继续;若已写入前缀已前进,以 `GRIDFS_RESUME_MISMATCH.expectedNextChunk` 为准。正常读到 EOF 后,若输入流在自动销毁期间报错,追加仍会失败。内存占用受当前流缓冲、未凑齐的分片和固定大小的分片索引批次限制。大文件应使用较小的 `highWaterMark`。 `appendChunks` 以 `fromChunk` 定位: diff --git a/scripts/maintenance/gridfs-recover.cjs b/scripts/maintenance/gridfs-recover.cjs index ab65325..f92d2e9 100644 --- a/scripts/maintenance/gridfs-recover.cjs +++ b/scripts/maintenance/gridfs-recover.cjs @@ -272,7 +272,7 @@ async function recoverGridFSOwnership(input) { const changed = await uploads.updateOne(held, { $set: { status: 'pending', updatedAt: new Date(), expiresAt: new Date(Date.now() + TTL_MS) }, - $unset: { recovery: '' }, + $unset: { recovery: '', ownerToken: '' }, }); if (changed.matchedCount !== 1) fail('Lost recovery claim while restoring finalize'); } else { diff --git a/src/capabilities/gridfs/resumable.ts b/src/capabilities/gridfs/resumable.ts index 3c3e60c..2731f29 100644 --- a/src/capabilities/gridfs/resumable.ts +++ b/src/capabilities/gridfs/resumable.ts @@ -208,23 +208,27 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje throw uploadBusy('finalizeResumableUpload', ctx, uploadId); } if (session.status !== 'pending') throw uploadBusy('finalizeResumableUpload', ctx, uploadId); + const ownerToken = randomUUID(); + const owner = finalizingOwner(uploadId, ownerToken); const transitioned = await uploads.updateOne( { _id: uploadId, status: 'pending', activeAppend: { $exists: false } }, - { $set: { status: 'finalizing', updatedAt: new Date() }, $unset: { expiresAt: '' } }, + { $set: { status: 'finalizing', ownerToken, updatedAt: new Date() }, $unset: { expiresAt: '' } }, ); if (!transitioned.matchedCount) throw uploadBusy('finalizeResumableUpload', ctx, uploadId); // Only the winner of this CAS may inspect/commit files. Abort will now // fail its pending -> aborting CAS instead of deleting validated chunks. session = await requireSession(ctx, uploadId, 'finalizeResumableUpload'); + if (session.status !== 'finalizing' || session.ownerToken !== ownerToken) { + throw uploadBusy('finalizeResumableUpload', ctx, uploadId); + } const files = ctx.db.collection(filesCollectionName(ctx.bucketName)); - const restorePendingAfterClosedFailure = async (error: unknown): Promise => { - if (!isClosedFinalizeFailure(error)) return; + const restorePending = async (): Promise => { try { - await uploads.updateOne( - { _id: uploadId, status: 'finalizing' }, - { $set: { status: 'pending', updatedAt: new Date(), expiresAt: new Date(Date.now() + 604800 * 1000) } }, - ); + await uploads.updateOne(owner, { + $set: { status: 'pending', updatedAt: new Date(), expiresAt: new Date(Date.now() + 604800 * 1000) }, + $unset: { ownerToken: '' }, + }); } catch { /* The retained finalizing state must be recovered explicitly. */ } }; @@ -232,7 +236,7 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje const already = await committedFile(ctx, uploadId, session); if (already) return already; } catch (error) { - await restorePendingAfterClosedFailure(error); + if (isClosedFinalizeFailure(error)) await restorePending(); throw error; } @@ -278,12 +282,7 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje } catch (error) { // Validation failed before any files insert. Return to pending so the // caller can supply missing/correct chunks; do not erase those chunks. - try { - await uploads.updateOne( - { _id: uploadId, status: 'finalizing' }, - { $set: { status: 'pending', updatedAt: new Date(), expiresAt: new Date(Date.now() + 604800 * 1000) } }, - ); - } catch { /* The retained finalizing state must be recovered explicitly. */ } + await restorePending(); throw error; } @@ -306,20 +305,20 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje const raced = await committedFile(ctx, uploadId, session); if (raced) return raced; } catch (conflict) { - await restorePendingAfterClosedFailure(conflict); + if (isClosedFinalizeFailure(conflict)) await restorePending(); throw conflict; } } const mapped = mapDriverError(error, 'finalizeResumableUpload', ctx, { uploadId, filename: session.filename }); if (isClosedFinalizeFailure(mapped)) { - await restorePendingAfterClosedFailure(mapped); + await restorePending(); } // Ambiguous commit outcomes keep finalizing ownership rather than // allowing an abort or another writer to delete/reuse the same chunks. throw mapped; } try { - await uploads.deleteOne({ _id: uploadId, status: 'finalizing' }); + await uploads.deleteOne(owner); } catch (error) { ctx.logger?.warn?.('[gridfs] finalizeResumableUpload could not delete the upload session', { bucket: ctx.bucketName, uploadId: String(uploadId), error }); } @@ -371,6 +370,11 @@ export async function abortResumableUpload(ctx: GridFSContext, uploadId: ObjectI * Returns null when no file exists. A file whose stored sha256 and length match the session is this upload's own * earlier commit: delete the leftover session and return it. Any other file is a real conflict (GRIDFS_FILE_EXISTS). */ +function finalizingOwner(uploadId: ObjectId, ownerToken?: string) { + // Legacy finalizing records have no token. Their retries can only clear legacy records. + return { _id: uploadId, status: 'finalizing', ownerToken: ownerToken ?? { $exists: false } }; +} + async function committedFile(ctx: GridFSContext, uploadId: ObjectId, session: GridFSUploadSession): Promise { const target = { uploadId, filename: session.filename }; try { @@ -404,7 +408,7 @@ async function committedFile(ctx: GridFSContext, uploadId: ObjectId, session: Gr if (index !== chunkCount(session.length, session.chunkSizeBytes) || total !== session.length || hash.digest('hex') !== session.sha256) { throw integrityMismatch('finalizeResumableUpload', ctx.bucketName, { uploadId, expected: session.sha256, actual: 'committed chunks do not match' }); } - await ctx.db.collection(uploadsCollectionName(ctx.bucketName)).deleteOne({ _id: uploadId, status: 'finalizing' }); + await ctx.db.collection(uploadsCollectionName(ctx.bucketName)).deleteOne(finalizingOwner(uploadId, session.ownerToken)); return info; } catch (error) { // GridFS domain errors (the conflict above) pass through unchanged. @@ -462,21 +466,20 @@ async function requireSession(ctx: GridFSContext, uploadId: ObjectId, operation: */ async function contiguousNext(ctx: GridFSContext, uploadId: ObjectId, from: number): Promise { const start = Number.isInteger(from) && from > 0 ? from : 0; - let docs; + let expected = start; try { - docs = await ctx.db.collection(chunksCollectionName(ctx.bucketName)) + const cursor = ctx.db.collection(chunksCollectionName(ctx.bucketName)) .find({ files_id: uploadId, n: { $gte: start } }) .sort({ n: 1 }) - .project({ n: 1 }) - .toArray(); + .project({ _id: 0, n: 1 }) + .batchSize(256); + for await (const doc of cursor) { + if (doc.n !== expected) break; + expected += 1; + } } catch (error) { throw mapDriverError(error, 'appendChunks', ctx, { uploadId }); } - let expected = start; - for (const doc of docs) { - if (doc.n !== expected) break; - expected += 1; - } return expected; } diff --git a/src/capabilities/gridfs/stream-chunks.ts b/src/capabilities/gridfs/stream-chunks.ts index 6b794bf..cc2a903 100644 --- a/src/capabilities/gridfs/stream-chunks.ts +++ b/src/capabilities/gridfs/stream-chunks.ts @@ -2,6 +2,8 @@ import type { Readable } from 'node:stream'; import { invalidArgument } from './errors'; import { chunkCount, chunkLengthAt } from './validate'; +const DESTROY_CHECK_MS = 50; + export interface StreamedChunk { index: number; bytes: Buffer; @@ -23,7 +25,9 @@ function isPrematureClose(source: Readable, ended: boolean): boolean { export function watchReadable(source: Readable) { let error: Error | undefined; let released = false; + let closeCheck: ReturnType | undefined; const detach = (): void => { + clearInterval(closeCheck); source.removeListener('error', onError); source.removeListener('close', detach); }; @@ -42,12 +46,17 @@ export function watchReadable(source: Readable) { detach(); return; } - if (!source.closed) source.once('close', detach); - if (!source.destroyed) source.destroy(); - // A synchronous _destroy can queue an error even after closed is true. - setImmediate(() => { + if (source.closed) { + // A synchronous _destroy may have already queued an error. + setImmediate(detach); + return; + } + source.once('close', detach); + closeCheck = setInterval(() => { if (source.closed) detach(); - }); + }, DESTROY_CHECK_MS); + closeCheck.unref(); + if (!source.destroyed) source.destroy(); }, }; } @@ -159,8 +168,8 @@ export async function consumeReadableChunks( }; const onError = (error: unknown): void => abort(error); const onClose = (): void => { - if (ended || source.readableEnded) return; - abort(streamClosedError()); + if (ended || source.readableEnded) run(); + else abort(streamClosedError()); }; const pump = async (): Promise => { @@ -204,7 +213,9 @@ export async function consumeReadableChunks( run(); return; } - if ((ended || source.readableEnded) && residual.length === 0) finish(); + // Node starts auto-destruction after end listeners return. + // Once it starts, wait for its result before accepting the append. + if (ended && (!source.destroyed || source.closed)) finish(); }, (error: unknown) => finish(error)); }; @@ -215,7 +226,8 @@ export async function consumeReadableChunks( // emitClose:false suppresses the notification even when an idle source is destroyed. const checkDestroyed = setInterval(() => { if (isPrematureClose(source, ended)) abort(stream.error ?? streamClosedError()); - }, 50); + else if (ended && source.closed) run(); + }, DESTROY_CHECK_MS); checkDestroyed.unref(); run(); if (!ended && source.readableEnded) { diff --git a/src/capabilities/gridfs/types.ts b/src/capabilities/gridfs/types.ts index 6a3b00d..4c4cfa4 100644 --- a/src/capabilities/gridfs/types.ts +++ b/src/capabilities/gridfs/types.ts @@ -86,6 +86,7 @@ export interface GridFSUploadSession { contentType?: string; kind?: 'resumable'; status: 'initializing' | 'pending' | 'finalizing' | 'aborting' | 'finalized' | 'aborted'; + ownerToken?: string; activeAppend?: { token: string; startedAt: Date }; expiresAt?: Date; nextChunk: number; diff --git a/test/integration/gridfs/gridfs-remaining-issues.test.ts b/test/integration/gridfs/gridfs-remaining-issues.test.ts index 045dad3..fba699c 100644 --- a/test/integration/gridfs/gridfs-remaining-issues.test.ts +++ b/test/integration/gridfs/gridfs-remaining-issues.test.ts @@ -177,6 +177,83 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str }); }); + it('keeps the new finalize owner when an older retry completes after ID reuse', { timeout: 10_000 }, async () => { + await withRuntime('gridfs_finalize_owner_reuse', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const id = new ObjectId(); + const oldBytes = Buffer.from('OLD!'); + const newBytes = Buffer.from('NEW!'); + const firstCleanup = gate(); + const retryCleanup = gate(); + const newInsert = gate(); + const finishFirst = gate(); + const finishRetry = gate(); + const finishNew = gate(); + const originalDelete = Collection.prototype.deleteOne; + const originalInsert = Collection.prototype.insertOne; + await bucket.beginResumableUpload('old.bin', { id, length: 4, sha256: sha(oldBytes) }); + await bucket.appendChunks(id, 0, oldBytes); + let cleanups = 0; + Collection.prototype.deleteOne = async function (this: Collection, filter, options) { + if (this.collectionName === 'b.uploads' && filter?.status === 'finalizing' && id.equals(filter._id)) { + cleanups += 1; + if (cleanups === 1) { + firstCleanup.open(); + await finishFirst.wait; + } else if (cleanups === 2) { + retryCleanup.open(); + await finishRetry.wait; + } + } + return originalDelete.call(this, filter, options); + }; + Collection.prototype.insertOne = async function (this: Collection, doc, options) { + if (this.collectionName === 'b.files' && doc.filename === 'new.bin') { + newInsert.open(); + await finishNew.wait; + } + return originalInsert.call(this, doc, options); + }; + const pending = []; + try { + const first = bucket.finalizeResumableUpload(id); + pending.push(first); + await firstCleanup.wait; + const oldRetry = bucket.finalizeResumableUpload(id); + pending.push(oldRetry); + await retryCleanup.wait; + finishFirst.open(); + await first; + await bucket.delete(id); + await bucket.beginResumableUpload('new.bin', { id, length: 4, sha256: sha(newBytes) }); + await bucket.appendChunks(id, 0, newBytes); + const current = bucket.finalizeResumableUpload(id); + pending.push(current); + await newInsert.wait; + const uploads = db.collection('b.uploads'); + const owner = await uploads.findOne({ _id: id }); + assert.equal(owner?.status, 'finalizing'); + + finishRetry.open(); + assert.equal((await oldRetry).filename, 'old.bin'); + assert.deepEqual(await uploads.findOne({ _id: id }), owner); + await assert.rejects(bucket.abortResumableUpload(id), { code: 'GRIDFS_UPLOAD_BUSY' }); + + finishNew.open(); + assert.equal((await current).filename, 'new.bin'); + assert.deepEqual(await bucket.download(id), newBytes); + assert.equal(await uploads.findOne({ _id: id }), null); + } finally { + finishFirst.open(); + finishRetry.open(); + finishNew.open(); + Collection.prototype.deleteOne = originalDelete; + Collection.prototype.insertOne = originalInsert; + await Promise.allSettled(pending); + } + }); + }); + it('rejects malformed or extra chunk indexes even when the expected bytes are present', async () => { await withRuntime('gridfs_finalize_invalid_index', async (runtime, db) => { const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); @@ -335,6 +412,66 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str }); }); + it('rejects an append when automatic destruction fails after EOF', async () => { + await withRuntime('gridfs_append_eof_error', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const payload = Buffer.from('GOOD'); + const { uploadId } = await bucket.beginResumableUpload('eof.bin', { length: 4, sha256: sha(payload) }); + const destroying = gate(); + const complete = gate(); + const failure = new Error('asynchronous EOF cleanup failed'); + const source = new Readable({ + read() { this.push(payload); this.push(null); }, + destroy(_error, callback) { + destroying.open(); + void complete.wait.then(() => callback(failure)); + }, + }); + const rejected = assert.rejects(bucket.appendChunks(uploadId, 0, source), (error) => error === failure); + try { + await destroying.wait; + await assert.rejects(bucket.abortResumableUpload(uploadId), { code: 'GRIDFS_UPLOAD_BUSY' }); + } finally { + complete.open(); + await rejected; + } + const owner = await db.collection('b.uploads').findOne({ _id: uploadId }); + assert.equal(owner?.nextChunk, 0); + assert.equal(owner?.activeAppend, undefined); + assert.equal(await db.collection('b.chunks').countDocuments({ files_id: uploadId }), 1); + assert.equal((await bucket.appendChunks(uploadId, 0, payload)).nextChunk, 1); + }); + }); + + it('scans the stored prefix in bounded batches after a large stream append', async () => { + await withRuntime('gridfs_append_batch_scan', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 1 }); + const payload = Buffer.alloc(600, 7); + const { uploadId } = await bucket.beginResumableUpload('batched.bin', { length: payload.length, sha256: sha(payload) }); + const original = Collection.prototype.find; + const batchSizes: number[] = []; + Collection.prototype.find = function (this: Collection, ...args: unknown[]) { + const cursor = Reflect.apply(original, this, args); + const filter = args[0] as { files_id?: ObjectId; n?: { $gte?: number } } | undefined; + if (this.collectionName === 'b.chunks' && uploadId.equals(filter?.files_id) && filter?.n?.$gte !== undefined) { + const batchSize = cursor.batchSize.bind(cursor); + cursor.batchSize = (size: number) => { batchSizes.push(size); return batchSize(size); }; + cursor.toArray = async () => { throw new Error('prefix scan materialized all matching chunk indexes'); }; + } + return cursor; + } as typeof original; + try { + const result = await bucket.appendChunks(uploadId, 0, Readable.from([payload])); + assert.deepEqual(result, { nextChunk: 600, bytesAccepted: 600 }); + assert.deepEqual(batchSizes, [256, 256]); + } finally { + Collection.prototype.find = original; + } + await bucket.finalizeResumableUpload(uploadId); + assert.deepEqual(await bucket.download(uploadId), payload); + }); + }); + it('keeps an unread source reusable after pre-consumption rejections', async () => { await withRuntime('gridfs_append_unread_retry', async (runtime, db) => { const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); diff --git a/test/unit/gridfs/gridfs.test.ts b/test/unit/gridfs/gridfs.test.ts index c466b43..50c3275 100644 --- a/test/unit/gridfs/gridfs.test.ts +++ b/test/unit/gridfs/gridfs.test.ts @@ -80,15 +80,20 @@ class MemoryCollection { find(filter: Doc) { let rows = this.matched(filter); + const countRow = () => { this.rowsRead += 1; }; const count = (out: Doc[]) => { this.rowsRead += out.length; return out; }; const cursor = { sort: (spec: Doc) => { rows = sortRows(rows, spec); return cursor; }, skip: (count: number) => { rows = rows.slice(count); return cursor; }, limit: (count: number) => { rows = rows.slice(0, count); return cursor; }, project: () => cursor, + batchSize: () => cursor, toArray: async () => count(rows).map((row) => ({ ...row })), async *[Symbol.asyncIterator]() { - for (const row of rows) yield { ...row }; + for (const row of rows) { + countRow(); + yield { ...row }; + } }, }; return cursor; diff --git a/test/unit/gridfs/stream-chunks.test.ts b/test/unit/gridfs/stream-chunks.test.ts index 57cd5a1..776b5eb 100644 --- a/test/unit/gridfs/stream-chunks.test.ts +++ b/test/unit/gridfs/stream-chunks.test.ts @@ -208,6 +208,30 @@ describe('consumeReadableChunks', () => { assert.equal(source.listenerCount('close'), 0); }); + it('removes its listeners after silent asynchronous destruction completes', async () => { + let finishDestroy = (): void => {}; + const source = new Readable({ + emitClose: false, + read() {}, + destroy(error, callback) { + finishDestroy = () => callback(error); + }, + }); + const result = consumeReadableChunks(source, 0, 4, 4, async () => undefined); + setImmediate(() => source.destroy()); + try { + await assert.rejects(settles(result), isClosedError); + assert.equal(source.closed, false); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + finishDestroy(); + } + await delayed(100); + assert.equal(source.closed, true); + assert.equal(source.listenerCount('error'), 0); + assert.equal(source.listenerCount('close'), 0); + }); + it('waits for the in-flight write and discards the remaining buffered chunks on destroy', async () => { let writeFinished = false; let pushed = false; From 87a3dca5ce623e35d7d2bde063ede8d2bc43fc4c Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 11:01:52 +0800 Subject: [PATCH 09/12] fix(gridfs): return metadata from the finalized upload --- src/capabilities/gridfs/resumable.ts | 27 ++++----- .../gridfs/gridfs-remaining-issues.test.ts | 55 +++++++++++++++++++ 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/capabilities/gridfs/resumable.ts b/src/capabilities/gridfs/resumable.ts index 2731f29..df235a6 100644 --- a/src/capabilities/gridfs/resumable.ts +++ b/src/capabilities/gridfs/resumable.ts @@ -290,15 +290,16 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje buildMetadata(session.metadata, session.sha256, 'finalizeResumableUpload'), session.contentType, ); + const file = { + _id: uploadId, + length: session.length, + chunkSize: session.chunkSizeBytes, + uploadDate: new Date(), + filename: session.filename, + ...(metadata ? { metadata } : {}), + }; try { - await files.insertOne({ - _id: uploadId, - length: session.length, - chunkSize: session.chunkSizeBytes, - uploadDate: new Date(), - filename: session.filename, - ...(metadata ? { metadata } : {}), - }); + await files.insertOne(file); } catch (error) { if (isDuplicateKey(error)) { try { @@ -322,14 +323,8 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje } catch (error) { ctx.logger?.warn?.('[gridfs] finalizeResumableUpload could not delete the upload session', { bucket: ctx.bucketName, uploadId: String(uploadId), error }); } - let info; - try { - info = await files.findOne({ _id: uploadId }); - } catch (error) { - throw mapDriverError(error, 'finalizeResumableUpload', ctx, { uploadId, filename: session.filename }); - } - if (!info) throw fileNotFound('finalizeResumableUpload', ctx, { id: uploadId, filename: session.filename }); - return toFileInfo(info as Record); + // The ID may be reused as soon as the finalize owner is released. + return toFileInfo(file); } export async function abortResumableUpload(ctx: GridFSContext, uploadId: ObjectId): Promise { diff --git a/test/integration/gridfs/gridfs-remaining-issues.test.ts b/test/integration/gridfs/gridfs-remaining-issues.test.ts index fba699c..16692b5 100644 --- a/test/integration/gridfs/gridfs-remaining-issues.test.ts +++ b/test/integration/gridfs/gridfs-remaining-issues.test.ts @@ -177,6 +177,61 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str }); }); + it('returns the file committed by this finalize after a concurrent deletion and ID reuse', { timeout: 10_000 }, async () => { + await withRuntime('gridfs_finalize_result_reuse', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const id = new ObjectId(); + const firstBytes = Buffer.from('OLD!'); + const replacementBytes = Buffer.from('NEW!NEW!'); + await bucket.beginResumableUpload('first.bin', { + id, length: firstBytes.length, sha256: sha(firstBytes), + contentType: 'application/octet-stream', metadata: { label: 'first' }, + }); + await bucket.appendChunks(id, 0, firstBytes); + + const released = gate(); + const returnFirst = gate(); + const original = Collection.prototype.deleteOne; + let pauseFirst = true; + Collection.prototype.deleteOne = async function (this: Collection, filter, options) { + const result = await original.call(this, filter, options); + if (pauseFirst && this.collectionName === 'b.uploads' && filter?.status === 'finalizing' && id.equals(filter._id)) { + pauseFirst = false; + released.open(); + await returnFirst.wait; + } + return result; + }; + const first = bucket.finalizeResumableUpload(id); + try { + await released.wait; + const committed = await bucket.getFile(id); + assert.equal(committed?.filename, 'first.bin'); + assert.equal(committed?.length, firstBytes.length); + assert.equal(committed?.metadata?.sha256, sha(firstBytes)); + assert.equal(committed?.contentType, 'application/octet-stream'); + + await bucket.delete(id); + await bucket.beginResumableUpload('replacement.bin', { + id, length: replacementBytes.length, sha256: sha(replacementBytes), + chunkSizeBytes: 8, metadata: { label: 'replacement' }, + }); + await bucket.appendChunks(id, 0, replacementBytes); + const replacement = await bucket.finalizeResumableUpload(id); + + returnFirst.open(); + assert.deepEqual(await first, committed); + assert.deepEqual(await bucket.getFile(id), replacement); + assert.deepEqual(await bucket.download(id), replacementBytes); + assert.equal(await db.collection('b.uploads').findOne({ _id: id }), null); + } finally { + returnFirst.open(); + Collection.prototype.deleteOne = original; + await Promise.allSettled([first]); + } + }); + }); + it('keeps the new finalize owner when an older retry completes after ID reuse', { timeout: 10_000 }, async () => { await withRuntime('gridfs_finalize_owner_reuse', async (runtime, db) => { const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); From d31b73ac6817e1d531dcfb8a6569414b19817727 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 11:29:16 +0800 Subject: [PATCH 10/12] fix(gridfs): harden retry, recovery reads and diagnostics --- config/package-budget.json | 6 +- docs/en/gridfs-recovery.md | 2 +- docs/en/gridfs.md | 4 +- docs/zh/gridfs-recovery.md | 2 +- docs/zh/gridfs.md | 4 +- .../maintenance/gridfs-inspect-orphans.cjs | 27 ++--- scripts/maintenance/gridfs-recover.cjs | 13 +- src/capabilities/gridfs/errors.ts | 6 +- src/capabilities/gridfs/operations.ts | 22 ++-- src/capabilities/gridfs/ownership.ts | 6 +- src/capabilities/gridfs/resumable.ts | 64 +++++----- .../gridfs/gridfs-inspect-orphans.test.ts | 24 ++++ .../gridfs/gridfs-remaining-issues.test.ts | 112 ++++++++++++++++++ 13 files changed, 218 insertions(+), 74 deletions(-) diff --git a/config/package-budget.json b/config/package-budget.json index ac57acb..44862d0 100644 --- a/config/package-budget.json +++ b/config/package-budget.json @@ -1,8 +1,8 @@ { - "comment": "GridFS initialization fencing and Readable teardown bring the 2026-11-13 build to about 896200 CJS and 893500 ESM, with no dependency change. Ceilings retain less than 1 KiB of headroom for these measured builds and are enforced in release preflight.", + "comment": "GridFS ownership checks, primary reads and bounded diagnostics bring the 2026-11-13 build to 897375 CJS and 894610 ESM bytes, with no dependency change. Release preflight enforces these ceilings.", "artifacts": { - "dist/cjs/index.cjs": 897000, - "dist/esm/index.mjs": 894000, + "dist/cjs/index.cjs": 898000, + "dist/esm/index.mjs": 895000, "dist/cjs/cli/data-task.cjs": 50000 } } diff --git a/docs/en/gridfs-recovery.md b/docs/en/gridfs-recovery.md index 7eba8d6..888f912 100644 --- a/docs/en/gridfs-recovery.md +++ b/docs/en/gridfs-recovery.md @@ -8,7 +8,7 @@ The ownership registry is the per-bucket `.uploads` collection. A reserv The script `scripts/maintenance/gridfs-recover.cjs` uses a default read-only preview, exact state/token/timestamp checks, a persisted `recovering` ownership claim, verified preservation of committed visible chunks, and an append-only-by-intent audit collection `.ownership_recovery_audit`. It never performs a time-based lock steal. Dry-run plans may use a pre-claim visibility snapshot; **apply always re-reads `files` after the exclusive claim**. If visibility changed, or `verifyVisibleFile` cannot confirm a visible file that the plan required, the tool fails closed and leaves the `recovering` owner in place. -To survey unknown-id leftover chunks without deleting anything, use the inspect-only reporter `scripts/maintenance/gridfs-inspect-orphans.cjs`. It walks `.chunks` in index order in bounded batches, re-checks `files` after finding neither a file nor an owner for a `files_id`, and reports `visible_file`, `owned`, or `candidate_orphan`. It has no apply mode, does not delete, and does not use document age as a deletion criterion. It is not part of the public `GridFSBucket` API. The CLI emits one NDJSON finding per line plus a final summary object. `--max-findings` (default 1000, cap 100000) bounds memory; a truncated scan returns `resumeAfter.filesId` for the next `--after-files-id`. Summary counters accumulate incrementally. +To survey leftover chunks with ObjectId file IDs, use the inspect-only reporter `scripts/maintenance/gridfs-inspect-orphans.cjs`. It walks `.chunks` in index order in bounded batches, re-checks `files` after finding neither a file nor an owner for a `files_id`, and reports `visible_file`, `owned`, or `candidate_orphan`. It has no apply mode, does not delete, and does not use document age as a deletion criterion. It is not part of the public `GridFSBucket` API. The CLI emits one NDJSON finding per line plus a final summary object. `--max-findings` (default 1000, cap 100000) bounds memory; a truncated scan returns `resumeAfter.filesId` for the next `--after-files-id`. Summary counters accumulate incrementally. ```sh export MONSQLIZE_RECOVERY_MONGODB_URI='' diff --git a/docs/en/gridfs.md b/docs/en/gridfs.md index 2c05799..45e85ef 100644 --- a/docs/en/gridfs.md +++ b/docs/en/gridfs.md @@ -92,9 +92,9 @@ Chunk bytes must follow the session size: - A payload that contains any new chunk must start at `nextChunk`. Starting earlier throws `GRIDFS_RESUME_MISMATCH` and writes nothing. - A duplicate `{ files_id, n }` insert is accepted only when the stored bytes match. -`finalizeResumableUpload` requires exactly one chunk at each expected index starting at zero, a total length equal to the session length, and a sha256 equal to the session digest. It then inserts the files document, sets `metadata.sha256`, and deletes the session. Missing chunk indexes throw `GRIDFS_UPLOAD_INCOMPLETE` with `details[0].missingChunks`. An invalid, duplicate, or out-of-range chunk index, a bad digest, or a chunk of the wrong size throws `GRIDFS_INTEGRITY_MISMATCH`. The files document is not inserted in those cases. +`finalizeResumableUpload` requires exactly one chunk at each expected index starting at zero, a total length equal to the session length, and a sha256 equal to the session digest. It then inserts the files document, sets `metadata.sha256`, and deletes the session. Missing chunk indexes throw `GRIDFS_UPLOAD_INCOMPLETE`. `details[0].missingChunks` lists the first 1,024 missing indexes at most. When truncated, `details[0].missingChunkCount` gives the total. An invalid, duplicate, or out-of-range chunk index, a bad digest, or a chunk of the wrong size throws `GRIDFS_INTEGRITY_MISMATCH`. The files document is not inserted in those cases. -Retrying `finalizeResumableUpload` is idempotent while the session still exists: if a file with the same id is already visible, the library re-hashes the actual stored chunks and returns the file only when that digest, the file length, and the chunk size match the session. A different sha256 or length throws `GRIDFS_FILE_EXISTS`. A chunk-size mismatch throws `GRIDFS_INTEGRITY_MISMATCH` and retains the existing finalizing session. Once the session is gone (a finalize that fully succeeded, an abort, or the TTL), there is no digest to compare, so a retry throws `GRIDFS_FILE_NOT_FOUND`. Callers must persist `uploadId`, `length`, and `sha256` themselves. After a vanished session, use `getFile(uploadId)` to see whether a file is visible, then re-hash the **actual** bytes (for example `download(uploadId)` or a walk of `.chunks`) and compare them to the persisted length and digest. Never treat `metadata.sha256` alone as proof: `uploadStream()` and a raw `metadata.sha256` can store an unverified claim. This package does not add a separate public verification API. +Retrying `finalizeResumableUpload` is idempotent while the session still exists: if a file with the same id is already visible, the library re-hashes the actual stored chunks and returns the file only when that digest, the file length, and the chunk size match the session. A different sha256 or length throws `GRIDFS_FILE_EXISTS`. A chunk-size mismatch throws `GRIDFS_INTEGRITY_MISMATCH` and retains the existing finalizing session. A retry that observes a different upload under the same ID returns `GRIDFS_UPLOAD_BUSY`. Once the session is gone (a finalize that fully succeeded, an abort, or the TTL), there is no digest to compare, so a retry throws `GRIDFS_FILE_NOT_FOUND`. Callers must persist `uploadId`, `length`, and `sha256` themselves. After a vanished session, use `getFile(uploadId)` to see whether a file is visible, then re-hash the **actual** bytes (for example `download(uploadId)` or a walk of `.chunks`) and compare them to the persisted length and digest. Never treat `metadata.sha256` alone as proof: `uploadStream()` and a raw `metadata.sha256` can store an unverified claim. This package does not add a separate public verification API. A `finalizeResumableUpload` that fails with `GRIDFS_FILE_EXISTS` or `GRIDFS_INTEGRITY_MISMATCH` after it has claimed `finalizing` rolls the session back to `pending` so `abortResumableUpload` (and then a delete of a conflicting visible file) can proceed through the public API. Ambiguous driver errors during the files insert still leave `finalizing` for explicit recovery. diff --git a/docs/zh/gridfs-recovery.md b/docs/zh/gridfs-recovery.md index 20489fb..7370b0e 100644 --- a/docs/zh/gridfs-recovery.md +++ b/docs/zh/gridfs-recovery.md @@ -10,7 +10,7 @@ dry-run 可以使用领取所有权之前的可见性快照;**apply 必须在独占 claim 之后重新读取 `files`**。若可见性已经变化,或在需要可见文件时 `verifyVisibleFile` 无法确认,脚本失败并保留 `recovering` 所有权。 -排查未知 ID 的残留 chunks 时,使用只读巡检脚本 `scripts/maintenance/gridfs-inspect-orphans.cjs`。它按已有 `{ files_id, n }` 索引分批扫描 `.chunks`。分类时若未查到 `files` 和 `uploads` 记录,会再次核对 `files`,报告 `visible_file`、`owned` 或 `candidate_orphan`。没有 apply 模式,不会删除,也不会按文档年龄删除。它不是公开的 `GridFSBucket` API。CLI 按行输出 NDJSON 结果及一条摘要。`--max-findings`(默认 1000,上限 100000)限制单次内存;截断时用 `resumeAfter.filesId` 作为下次的 `--after-files-id`。摘要计数在扫描过程中增量累计。 +排查使用 ObjectId 的残留 chunks 时,使用只读巡检脚本 `scripts/maintenance/gridfs-inspect-orphans.cjs`。它按已有 `{ files_id, n }` 索引分批扫描 `.chunks`。分类时若未查到 `files` 和 `uploads` 记录,会再次核对 `files`,报告 `visible_file`、`owned` 或 `candidate_orphan`。没有 apply 模式,不会删除,也不会按文档年龄删除。它不是公开的 `GridFSBucket` API。CLI 按行输出 NDJSON 结果及一条摘要。`--max-findings`(默认 1000,上限 100000)限制单次内存;截断时用 `resumeAfter.filesId` 作为下次的 `--after-files-id`。摘要计数在扫描过程中增量累计。 ```sh export MONSQLIZE_RECOVERY_MONGODB_URI='<从密钥管理系统读取的 MongoDB URI>' diff --git a/docs/zh/gridfs.md b/docs/zh/gridfs.md index c845685..02cceaf 100644 --- a/docs/zh/gridfs.md +++ b/docs/zh/gridfs.md @@ -92,9 +92,9 @@ await bucket.abortResumableUpload(session.uploadId); - 只要 payload 含有新分片,`fromChunk` 就必须等于 `nextChunk`。从更早的序号开始会抛出 `GRIDFS_RESUME_MISMATCH`,并且不会写入。 - `{ files_id, n }` 唯一索引冲突只有在已存字节相同的时候才算成功。 -`finalizeResumableUpload` 要求从 0 开始的每个预期序号恰好对应一个分片,总长度等于会话长度,且 sha256 等于会话摘要。然后插入 files 文档、写入 `metadata.sha256`,并删除会话。缺少的分片序号抛出 `GRIDFS_UPLOAD_INCOMPLETE`,`details[0].missingChunks` 列出缺口。序号无效、重复或越界,以及摘要或分片长度不符,均抛出 `GRIDFS_INTEGRITY_MISMATCH`。这些失败都不会插入 files 文档。 +`finalizeResumableUpload` 要求从 0 开始的每个预期序号恰好对应一个分片,总长度等于会话长度,且 sha256 等于会话摘要。然后插入 files 文档、写入 `metadata.sha256`,并删除会话。缺少分片时抛出 `GRIDFS_UPLOAD_INCOMPLETE`。`details[0].missingChunks` 最多列出前 1,024 个缺失序号,截断时 `details[0].missingChunkCount` 给出缺失总数。序号无效、重复或越界,以及摘要或分片长度不符,均抛出 `GRIDFS_INTEGRITY_MISMATCH`。这些失败都不会插入 files 文档。 -会话仍在时,重试 `finalizeResumableUpload` 是幂等的:若同 id 的文件已可见,库会重新哈希**实际存储的 chunks**,只有摘要、文件长度和分片大小都与会话一致时才返回该文件并删除会话。sha256 或长度不一致时抛出 `GRIDFS_FILE_EXISTS`。分片大小不一致时抛出 `GRIDFS_INTEGRITY_MISMATCH`,并保留已有的 `finalizing` 会话。会话已不存在时(上一次 finalize 已完整成功、已 abort 或 TTL 过期),没有可比对的摘要,重试抛 `GRIDFS_FILE_NOT_FOUND`。调用方必须自己持久化 `uploadId`、`length` 和 `sha256`。会话消失后,先用 `getFile(uploadId)` 看文件是否可见,再对**实际字节**重新哈希(例如 `download(uploadId)` 或遍历 `.chunks`),与自己保存的长度和摘要比对。不要只信任 `metadata.sha256`:`uploadStream()` 和调用方写入的 `metadata.sha256` 可以是未校验的声明。本包不为此再提供新的公开校验 API。 +会话仍在时,重试 `finalizeResumableUpload` 是幂等的:若同 id 的文件已可见,库会重新哈希**实际存储的 chunks**,只有摘要、文件长度和分片大小都与会话一致时才返回该文件并删除会话。sha256 或长度不一致时抛出 `GRIDFS_FILE_EXISTS`。分片大小不一致时抛出 `GRIDFS_INTEGRITY_MISMATCH`,并保留已有的 `finalizing` 会话。重试期间若同一 ID 已由另一次上传接管,则返回 `GRIDFS_UPLOAD_BUSY`。会话已不存在时(上一次 finalize 已完整成功、已 abort 或 TTL 过期),没有可比对的摘要,重试抛 `GRIDFS_FILE_NOT_FOUND`。调用方必须自己持久化 `uploadId`、`length` 和 `sha256`。会话消失后,先用 `getFile(uploadId)` 看文件是否可见,再对**实际字节**重新哈希(例如 `download(uploadId)` 或遍历 `.chunks`),与自己保存的长度和摘要比对。不要只信任 `metadata.sha256`:`uploadStream()` 和调用方写入的 `metadata.sha256` 可以是未校验的声明。本包不为此再提供新的公开校验 API。 `finalizeResumableUpload` 在已经占有 `finalizing` 之后,若因 `GRIDFS_FILE_EXISTS` 或 `GRIDFS_INTEGRITY_MISMATCH` 失败,会把会话 CAS 回 `pending`,以便通过公开 API 执行 `abortResumableUpload`(然后再删除冲突的可见文件)。files 插入过程中含义不明的驱动错误仍会保留 `finalizing`,需显式恢复。 diff --git a/scripts/maintenance/gridfs-inspect-orphans.cjs b/scripts/maintenance/gridfs-inspect-orphans.cjs index aa7099c..4c70e96 100644 --- a/scripts/maintenance/gridfs-inspect-orphans.cjs +++ b/scripts/maintenance/gridfs-inspect-orphans.cjs @@ -1,7 +1,7 @@ 'use strict'; /** - * Operator-only, inspect-only reporter for GridFS chunk files_id groups. + * Operator-only, inspect-only reporter for ObjectId files_id groups. * Walks .chunks via the existing { files_id, n } index in bounded batches. * Each files_id is classified only after a unified re-check of files and uploads: * - visible_file: a files document exists @@ -34,11 +34,11 @@ async function classifyFilesId(db, bucket, group) { const files = db.collection(bucket + '.files'); const uploads = db.collection(bucket + '.uploads'); const fileFilter = { _id: group.id }; - const fileProjection = { projection: { _id: 1 } }; + const fileProjection = { projection: { _id: 1 }, readPreference: 'primary' }; let file = await files.findOne(fileFilter, fileProjection); const owner = await uploads.findOne( { _id: group.id }, - { projection: { _id: 1, kind: 1, status: 1 } }, + { projection: { _id: 1, kind: 1, status: 1 }, readPreference: 'primary' }, ); // A successful upload publishes the file before it releases its owner. if (!file && !owner) file = await files.findOne(fileFilter, fileProjection); @@ -47,7 +47,7 @@ async function classifyFilesId(db, bucket, group) { else if (owner) classification = 'owned'; else classification = 'candidate_orphan'; const finding = { - filesId: group.id instanceof ObjectId ? group.id.toHexString() : String(group.id), + filesId: group.id.toHexString(), chunkCount: group.count, minN: group.minN, maxN: group.maxN, @@ -63,9 +63,9 @@ async function classifyFilesId(db, bucket, group) { function parseAfter(after) { if (after == null) return null; const raw = after.filesId ?? after.files_id; - if (raw == null || raw === '') fail('after.filesId is required to resume'); - const filesId = raw instanceof ObjectId ? raw : new ObjectId(String(raw)); - return filesId; + if (raw instanceof ObjectId) return raw; + if (typeof raw !== 'string' || !ObjectId.isValid(raw)) fail('after.filesId must be an ObjectId'); + return new ObjectId(raw); } async function inspectGridFSOrphans(input) { @@ -103,13 +103,16 @@ async function inspectGridFSOrphans(input) { return finding.filesId; }; - const cursor = chunks.find(afterId ? { files_id: { $gt: afterId } } : {}) + const cursor = chunks.find({ files_id: { $type: 'objectId', ...(afterId ? { $gt: afterId } : {}) } }, + { readPreference: 'primary' }) .sort({ files_id: 1, n: 1 }) .project({ _id: 0, files_id: 1, n: 1 }) .batchSize(pageSize); for await (const doc of cursor) { rows += 1; - if (!current || !sameId(current.id, doc.files_id)) { + // The query type predicate also matches arrays containing ObjectIds. + if (!(doc.files_id instanceof ObjectId)) continue; + if (!current || !current.id.equals(doc.files_id)) { if (current) { const filesId = await flush(current); if (filesIdsScanned === maxFindings) { @@ -142,12 +145,6 @@ async function inspectGridFSOrphans(input) { }; } -function sameId(left, right) { - if (left === right) return true; - if (left && right && typeof left.equals === 'function') return left.equals(right); - return String(left) === String(right); -} - function readArg(args, name) { const prefix = '--' + name + '='; const found = args.find((a) => a.startsWith(prefix)); diff --git a/scripts/maintenance/gridfs-recover.cjs b/scripts/maintenance/gridfs-recover.cjs index f92d2e9..70a7a06 100644 --- a/scripts/maintenance/gridfs-recover.cjs +++ b/scripts/maintenance/gridfs-recover.cjs @@ -39,7 +39,7 @@ function toBuffer(data) { async function verifyVisibleFile(db, bucket, id, session, owner) { const files = db.collection(bucket + '.files'); - const doc = await files.findOne({ _id: id }); + const doc = await files.findOne({ _id: id }, { readPreference: 'primary' }); if (!doc) return null; if (!Number.isSafeInteger(doc.length) || doc.length < 0 || !Number.isSafeInteger(doc.chunkSize) || doc.chunkSize < 1) { @@ -50,7 +50,7 @@ async function verifyVisibleFile(db, bucket, id, session, owner) { let n = 0; let length = 0; for await (const chunk of db.collection(bucket + '.chunks') - .find({ files_id: id }).sort({ n: 1 })) { + .find({ files_id: id }, { readPreference: 'primary' }).sort({ n: 1 })) { if (chunk.n !== n || n >= totalChunks) fail('Visible file has a missing, extra or out-of-order chunk'); const bytes = toBuffer(chunk.data); const want = n === totalChunks - 1 ? doc.length - n * doc.chunkSize : doc.chunkSize; @@ -146,7 +146,7 @@ async function contiguousChunkPrefix(db, bucket, id, session) { const count = Math.ceil(session.length / session.chunkSizeBytes); let next = 0; for await (const doc of db.collection(bucket + '.chunks') - .find({ files_id: id }).sort({ n: 1 })) { + .find({ files_id: id }, { readPreference: 'primary' }).sort({ n: 1 })) { if (doc.n !== next || next >= count) { fail('Interrupted append contains a gap or extra chunk; preserve ownership'); } @@ -166,7 +166,7 @@ async function recoverGridFSOwnership(input) { if (!db || !bucket || typeof bucket !== 'string' || !(id instanceof ObjectId)) fail('Invalid database, bucket or ObjectId'); if (!expectedStatus || !expectedUpdatedAt) fail('Exact expected status and updatedAt are required'); const uploads = db.collection(bucket + '.uploads'); - const doc = await uploads.findOne({ _id: id }); + const doc = await uploads.findOne({ _id: id }, { readPreference: 'primary' }); if (!doc) return { outcome: 'no_owner', modified: false }; if (!(doc.updatedAt instanceof Date) || doc.status !== expectedStatus || @@ -176,7 +176,8 @@ async function recoverGridFSOwnership(input) { const token = snapshotToken(doc); if ((token || null) !== (expectedToken || null)) fail('Owner token changed since inspection'); const sourceStatus = classify(doc); - const visible = await db.collection(bucket + '.files').findOne({ _id: id }, { projection: { _id: 1 } }); + const fileProjection = { projection: { _id: 1 }, readPreference: 'primary' }; + const visible = await db.collection(bucket + '.files').findOne({ _id: id }, fileProjection); const plan = { bucket, uploadId: id.toHexString(), fromStatus: sourceStatus, fileVisible: !!visible, @@ -255,7 +256,7 @@ async function recoverGridFSOwnership(input) { const files = db.collection(bucket + '.files'); // Dry-run may use the pre-claim snapshot. Apply must re-read visibility // after the exclusive claim and fail closed if it changed. - const visibleAfter = await files.findOne({ _id: id }, { projection: { _id: 1 } }); + const visibleAfter = await files.findOne({ _id: id }, fileProjection); const visibleNow = !!visibleAfter; if (visibleNow !== !!visible) { fail('File visibility changed after recovery claim; preserve recovering ownership'); diff --git a/src/capabilities/gridfs/errors.ts b/src/capabilities/gridfs/errors.ts index 2d5801e..3042165 100644 --- a/src/capabilities/gridfs/errors.ts +++ b/src/capabilities/gridfs/errors.ts @@ -12,6 +12,7 @@ export interface GridFSErrorDetails { expected?: unknown; actual?: unknown; missingChunks?: number[]; + missingChunkCount?: number; fromChunk?: number; expectedNextChunk?: number; start?: number; @@ -92,11 +93,12 @@ export function uploadBusy(operation: string, ctx: Pick missingChunks.length ? { missingChunkCount: missingCount } : {}) }, ); } diff --git a/src/capabilities/gridfs/operations.ts b/src/capabilities/gridfs/operations.ts index efc375a..40ffa66 100644 --- a/src/capabilities/gridfs/operations.ts +++ b/src/capabilities/gridfs/operations.ts @@ -68,7 +68,7 @@ export async function uploadFile( } await ctx.indexes.ensure(); - if (options.id && await getFile(ctx, options.id)) { + if (options.id && await getFile(ctx, options.id, 'primary')) { if (source) releaseReadable(source); throw fileExists('upload', ctx, { id: options.id, filename, sha256: options.sha256 }); } @@ -97,14 +97,14 @@ export async function uploadFile( throw error; } try { - if (await getFile(ctx, id)) { + if (await getFile(ctx, id, 'primary')) { if (source) releaseReadable(source); throw fileExists('upload', ctx, { id, filename, sha256: options.sha256 }); } const writeOptions = { chunkSizeBytes, metadata, contentType: options.contentType }; if (payload) await writeBuffer(ctx, id, filename, payload, writeOptions); else if (source) await writeReadable(ctx, id, filename, source, writeOptions); - const info = await getFile(ctx, id); + const info = await getFile(ctx, id, 'primary'); if (!info) throw fileNotFound('upload', ctx, { id, filename }); // A successful upload is committed even if removing its reservation fails. // Keep the durable reservation for explicit recovery rather than deleting the file. @@ -150,7 +150,7 @@ export function openUploadStream(ctx: GridFSContext, filename: string, opts?: Gr ...(typeof metadata?.sha256 === 'string' ? { claimedSha256: metadata.sha256 } : {}), }); // Check again after the ID is reserved, in case an older unreserved file exists. - if (await getFile(ctx, id)) { + if (await getFile(ctx, id, 'primary')) { throw fileExists('uploadStream', ctx, { id, filename, sha256: options.sha256 }); } target = ctx.driver.openUploadStream(id, filename, { @@ -160,7 +160,7 @@ export function openUploadStream(ctx: GridFSContext, filename: string, opts?: Gr }); const { pipeline } = await import('node:stream/promises'); await pipeline(input, target); - const info = await getFile(ctx, id); + const info = await getFile(ctx, id, 'primary'); if (!info) throw fileNotFound('uploadStream', ctx, { id, filename }); try { await releaseOwner(ctx, owner, 'uploadStream'); @@ -224,10 +224,10 @@ export function openDownloadStream(ctx: GridFSContext, idOrName: GridFSIdOrName, return output; } -export async function getFile(ctx: GridFSContext, id: ObjectId): Promise { +export async function getFile(ctx: GridFSContext, id: ObjectId, readPreference?: 'primary'): Promise { assertObjectId(id, 'getFile', 'id'); try { - const doc = await ctx.db.collection(filesCollectionName(ctx.bucketName)).findOne({ _id: id }); + const doc = await ctx.db.collection(filesCollectionName(ctx.bucketName)).findOne({ _id: id }, { readPreference }); return doc ? toFileInfo(doc as Record) : null; } catch (error) { throw mapDriverError(error, 'getFile', ctx, { id }); @@ -254,7 +254,7 @@ export async function deleteFile(ctx: GridFSContext, id: ObjectId, opts?: GridFS const owner = await claimOwner(ctx, id, 'delete', 'delete'); let driverInvoked = false; try { - const existing = await getFile(ctx, id); + const existing = await getFile(ctx, id, 'primary'); if (!existing) { await releaseOwner(ctx, owner, 'delete'); if (opts?.ignoreMissing) return; @@ -284,7 +284,7 @@ export async function renameFile(ctx: GridFSContext, id: ObjectId, filename: str assertFilename(filename, 'rename'); const owner = await claimOwner(ctx, id, 'rename', 'rename'); try { - const existing = await getFile(ctx, id); + const existing = await getFile(ctx, id, 'primary'); if (!existing) throw fileNotFound('rename', ctx, { id, filename }); try { await ctx.driver.rename(id, filename); @@ -333,7 +333,7 @@ async function findConflict(ctx: GridFSContext, filename: string, sha256: string const filter = sha256 !== undefined ? { 'metadata.sha256': sha256 } : { filename }; let docs; try { - docs = await files.find(filter).sort({ uploadDate: -1 }).limit(1).toArray(); + docs = await files.find(filter, { readPreference: 'primary' }).sort({ uploadDate: -1 }).limit(1).toArray(); } catch (error) { throw mapDriverError(error, 'upload', ctx, { filename }); } @@ -414,6 +414,6 @@ async function cleanupFailedWrite(ctx: GridFSContext, id: ObjectId, stream: Grid if (typeof stream.abort === 'function') await stream.abort(); } catch { /* A finished driver stream refuses abort; delete the visible file below. */ } try { - if (await getFile(ctx, id)) await ctx.driver.delete(id); + if (await getFile(ctx, id, 'primary')) await ctx.driver.delete(id); } catch { /* The caller still receives the original write error. */ } } diff --git a/src/capabilities/gridfs/ownership.ts b/src/capabilities/gridfs/ownership.ts index d26d531..e364a31 100644 --- a/src/capabilities/gridfs/ownership.ts +++ b/src/capabilities/gridfs/ownership.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import type { ObjectId } from 'mongodb'; import type { GridFSContext } from './types'; -import { fileExists, isDuplicateKey, mapDriverError, uploadBusy } from './errors'; +import { isDuplicateKey, mapDriverError, uploadBusy } from './errors'; import { chunksCollectionName, filesCollectionName, uploadsCollectionName } from './normalize'; /** @@ -81,7 +81,7 @@ export async function releaseOwner(ctx: GridFSContext, owner: GridFSOwner, opera export async function cleanupFailedDirect(ctx: GridFSContext, owner: GridFSOwner): Promise { try { const visible = await ctx.db.collection(filesCollectionName(ctx.bucketName)) - .findOne({ _id: owner.id }, { projection: { _id: 1 } }); + .findOne({ _id: owner.id }, { projection: { _id: 1 }, readPreference: 'primary' }); if (!visible) { await ctx.db.collection(chunksCollectionName(ctx.bucketName)) .deleteMany({ files_id: owner.id }); @@ -103,7 +103,7 @@ export async function cleanupOrphanById(ctx: GridFSContext, id: ObjectId): Promi let safeToRelease = false; try { const visible = await ctx.db.collection(filesCollectionName(ctx.bucketName)) - .findOne({ _id: id }, { projection: { _id: 1 } }); + .findOne({ _id: id }, { projection: { _id: 1 }, readPreference: 'primary' }); if (!visible) await ctx.db.collection(chunksCollectionName(ctx.bucketName)).deleteMany({ files_id: id }); safeToRelease = true; } catch (error) { diff --git a/src/capabilities/gridfs/resumable.ts b/src/capabilities/gridfs/resumable.ts index df235a6..70c4815 100644 --- a/src/capabilities/gridfs/resumable.ts +++ b/src/capabilities/gridfs/resumable.ts @@ -22,6 +22,8 @@ import { } from './validate'; import { consumeReadableChunks, watchReadable } from './stream-chunks'; +const MAX_REPORTED_MISSING_CHUNKS = 1024; + export async function beginResumableUpload(ctx: GridFSContext, filename: string, opts: BeginResumableUploadOptions): Promise { assertFilename(filename, 'beginResumableUpload'); if (!opts || typeof opts !== 'object') { @@ -39,10 +41,10 @@ export async function beginResumableUpload(ctx: GridFSContext, filename: string, const uploadId = opts.id ?? new ObjectId(); const files = ctx.db.collection(filesCollectionName(ctx.bucketName)); const uploads = ctx.db.collection(uploadsCollectionName(ctx.bucketName)); - if (await files.findOne({ _id: uploadId }, { projection: { _id: 1 } })) { + if (await files.findOne({ _id: uploadId }, { projection: { _id: 1 }, readPreference: 'primary' })) { throw fileExists('beginResumableUpload', ctx, { id: uploadId, filename, sha256: opts.sha256 }); } - if (await uploads.findOne({ _id: uploadId }, { projection: { _id: 1 } })) { + if (await uploads.findOne({ _id: uploadId }, { projection: { _id: 1 }, readPreference: 'primary' })) { throw fileExists('beginResumableUpload', ctx, { id: uploadId, filename, sha256: opts.sha256 }); } @@ -68,7 +70,7 @@ export async function beginResumableUpload(ctx: GridFSContext, filename: string, // Only publish a pending session after its orphan cleanup has settled. // Initializing has no TTL and excludes append, finalize, abort and ID reuse. try { - if (await files.findOne({ _id: uploadId }, { projection: { _id: 1 } })) { + if (await files.findOne({ _id: uploadId }, { projection: { _id: 1 }, readPreference: 'primary' })) { try { await uploads.deleteOne(initializing); } catch { /* Best-effort session cleanup; chunks of the visible file stay. */ } @@ -133,7 +135,7 @@ async function appendToSession( throw mapDriverError(error, 'appendChunks', ctx, { uploadId }); } if (!claimed) { - const existing = await uploads.findOne({ _id: uploadId }); + const existing = await uploads.findOne({ _id: uploadId }, { readPreference: 'primary' }); if (!existing) throw fileNotFound('appendChunks', ctx, { id: uploadId }); throw uploadBusy('appendChunks', ctx, uploadId); } @@ -196,7 +198,7 @@ async function appendToSession( export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: ObjectId): Promise { assertObjectId(uploadId, 'finalizeResumableUpload', 'uploadId'); const uploads = ctx.db.collection(uploadsCollectionName(ctx.bucketName)); - let session = await uploads.findOne({ _id: uploadId }) as unknown as GridFSUploadSession | null; + let session = await uploads.findOne({ _id: uploadId }, { readPreference: 'primary' }) as unknown as GridFSUploadSession | null; if (!session || (session.kind && session.kind !== 'resumable')) { throw fileNotFound('finalizeResumableUpload', ctx, { id: uploadId }); } @@ -245,8 +247,15 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje const hash = createHash('sha256'); let total = 0; const missing: number[] = []; - const cursor = ctx.db.collection(chunksCollectionName(ctx.bucketName)).find({ files_id: uploadId }).sort({ n: 1 }); + let missingCount = 0; + const cursor = ctx.db.collection(chunksCollectionName(ctx.bucketName)) + .find({ files_id: uploadId }, { readPreference: 'primary' }).sort({ n: 1 }); let seen = 0; + const recordGap = (end: number): void => { + missingCount += end - seen; + for (let n = seen; n < end && missing.length < MAX_REPORTED_MISSING_CHUNKS; n++) missing.push(n); + seen = end; + }; for await (const doc of cursor) { const n = doc.n; if (!Number.isSafeInteger(n) || n < seen || n >= expected) { @@ -254,10 +263,7 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje uploadId, expected: `chunk index between ${seen} and ${expected - 1}`, actual: n, }); } - while (seen < n) { - missing.push(seen); - seen += 1; - } + recordGap(n); const bytes = binaryToBuffer(doc.data, 'finalizeResumableUpload'); const want = chunkLengthAt(n, session.length, session.chunkSizeBytes); if (bytes.length !== want) { @@ -267,11 +273,8 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje total += bytes.length; seen += 1; } - while (seen < expected) { - missing.push(seen); - seen += 1; - } - if (missing.length > 0) throw uploadIncomplete(ctx.bucketName, uploadId, missing); + recordGap(expected); + if (missingCount > 0) throw uploadIncomplete(ctx.bucketName, uploadId, missing, missingCount); if (total !== session.length) { throw integrityMismatch('finalizeResumableUpload', ctx.bucketName, { uploadId, expected: session.length, actual: total }); } @@ -332,7 +335,7 @@ export async function abortResumableUpload(ctx: GridFSContext, uploadId: ObjectI const uploads = ctx.db.collection(uploadsCollectionName(ctx.bucketName)); let session: GridFSUploadSession | null; try { - session = await uploads.findOne({ _id: uploadId }) as unknown as GridFSUploadSession | null; + session = await uploads.findOne({ _id: uploadId }, { readPreference: 'primary' }) as unknown as GridFSUploadSession | null; } catch (error) { throw mapDriverError(error, 'abortResumableUpload', ctx, { uploadId }); } @@ -360,21 +363,25 @@ export async function abortResumableUpload(ctx: GridFSContext, uploadId: ObjectI } } -/** - * Resolve a finalize retry against an already visible file under `uploadId`. - * Returns null when no file exists. A file whose stored sha256 and length match the session is this upload's own - * earlier commit: delete the leftover session and return it. Any other file is a real conflict (GRIDFS_FILE_EXISTS). - */ function finalizingOwner(uploadId: ObjectId, ownerToken?: string) { // Legacy finalizing records have no token. Their retries can only clear legacy records. return { _id: uploadId, status: 'finalizing', ownerToken: ownerToken ?? { $exists: false } }; } +/** Verify an already visible file against the session that still owns this ID. */ async function committedFile(ctx: GridFSContext, uploadId: ObjectId, session: GridFSUploadSession): Promise { const target = { uploadId, filename: session.filename }; + const uploads = ctx.db.collection(uploadsCollectionName(ctx.bucketName)); + const owner = finalizingOwner(uploadId, session.ownerToken); try { - const doc = await ctx.db.collection(filesCollectionName(ctx.bucketName)).findOne({ _id: uploadId }); + const doc = await ctx.db.collection(filesCollectionName(ctx.bucketName)) + .findOne({ _id: uploadId }, { readPreference: 'primary' }); if (!doc) return null; + // After capturing the file, verify that the same finalize still owns + // this ID. Identical bytes can belong to a later upload with this ID. + if (!await uploads.findOne(owner, { projection: { _id: 1 }, readPreference: 'primary' })) { + throw uploadBusy('finalizeResumableUpload', ctx, uploadId); + } const info = toFileInfo(doc as Record); if (info.metadata?.sha256 !== session.sha256 || info.length !== session.length) { throw fileExists('finalizeResumableUpload', ctx, { id: uploadId, filename: session.filename, sha256: session.sha256 }); @@ -390,7 +397,7 @@ async function committedFile(ctx: GridFSContext, uploadId: ObjectId, session: Gr let total = 0; let index = 0; for await (const chunk of ctx.db.collection(chunksCollectionName(ctx.bucketName)) - .find({ files_id: uploadId }).sort({ n: 1 })) { + .find({ files_id: uploadId }, { readPreference: 'primary' }).sort({ n: 1 })) { const bytes = binaryToBuffer(chunk.data, 'finalizeResumableUpload'); const expectedSize = chunkLengthAt(index, session.length, session.chunkSizeBytes); if (chunk.n !== index || bytes.length !== expectedSize) { @@ -403,7 +410,7 @@ async function committedFile(ctx: GridFSContext, uploadId: ObjectId, session: Gr if (index !== chunkCount(session.length, session.chunkSizeBytes) || total !== session.length || hash.digest('hex') !== session.sha256) { throw integrityMismatch('finalizeResumableUpload', ctx.bucketName, { uploadId, expected: session.sha256, actual: 'committed chunks do not match' }); } - await ctx.db.collection(uploadsCollectionName(ctx.bucketName)).deleteOne(finalizingOwner(uploadId, session.ownerToken)); + await uploads.deleteOne(owner); return info; } catch (error) { // GridFS domain errors (the conflict above) pass through unchanged. @@ -416,7 +423,7 @@ async function removeInvisibleChunks(ctx: GridFSContext, uploadId: ObjectId, ope try { const visible = await ctx.db.collection(filesCollectionName(ctx.bucketName)).findOne( { _id: uploadId }, - { projection: { _id: 1 } }, + { projection: { _id: 1 }, readPreference: 'primary' }, ); if (!visible) await ctx.db.collection(chunksCollectionName(ctx.bucketName)).deleteMany({ files_id: uploadId }); } catch (error) { @@ -450,7 +457,7 @@ function planChunkSlices(payload: Buffer, fromChunk: number, length: number, chu } async function requireSession(ctx: GridFSContext, uploadId: ObjectId, operation: string): Promise { - const doc = await ctx.db.collection(uploadsCollectionName(ctx.bucketName)).findOne({ _id: uploadId }); + const doc = await ctx.db.collection(uploadsCollectionName(ctx.bucketName)).findOne({ _id: uploadId }, { readPreference: 'primary' }); if (!doc || (doc.kind && doc.kind !== 'resumable')) throw fileNotFound(operation, ctx, { id: uploadId }); return doc as unknown as GridFSUploadSession; } @@ -464,7 +471,7 @@ async function contiguousNext(ctx: GridFSContext, uploadId: ObjectId, from: numb let expected = start; try { const cursor = ctx.db.collection(chunksCollectionName(ctx.bucketName)) - .find({ files_id: uploadId, n: { $gte: start } }) + .find({ files_id: uploadId, n: { $gte: start } }, { readPreference: 'primary' }) .sort({ n: 1 }) .project({ _id: 0, n: 1 }) .batchSize(256); @@ -479,7 +486,8 @@ async function contiguousNext(ctx: GridFSContext, uploadId: ObjectId, from: numb } async function assertStoredChunk(ctx: GridFSContext, uploadId: ObjectId, n: number, slice: Buffer, expectedNextChunk: number): Promise { - const existing = await ctx.db.collection(chunksCollectionName(ctx.bucketName)).findOne({ files_id: uploadId, n }); + const existing = await ctx.db.collection(chunksCollectionName(ctx.bucketName)) + .findOne({ files_id: uploadId, n }, { readPreference: 'primary' }); if (!existing || !sameBytes(binaryToBuffer(existing.data, 'appendChunks'), slice)) { throw resumeMismatch('appendChunks', ctx.bucketName, { uploadId, fromChunk: n, expectedNextChunk }); } diff --git a/test/integration/gridfs/gridfs-inspect-orphans.test.ts b/test/integration/gridfs/gridfs-inspect-orphans.test.ts index 4cf2956..f46d4ac 100644 --- a/test/integration/gridfs/gridfs-inspect-orphans.test.ts +++ b/test/integration/gridfs/gridfs-inspect-orphans.test.ts @@ -166,6 +166,30 @@ describe('GridFS inspect-only orphan reporter', () => { }); }); + it('skips foreign IDs and preserves an ObjectId continuation on mixed collections', async () => { + await withDb(async (db) => { + const firstId = new ObjectId(); + const secondId = new ObjectId(); + await db.collection('b.chunks').insertMany([ + { files_id: 3, n: 0 }, + { files_id: 'foreign', n: 0 }, + { files_id: firstId.toHexString(), n: 0 }, + { files_id: firstId, n: 0 }, + { files_id: secondId, n: 0 }, + ]); + const first = await inspectGridFSOrphans({ db, bucket: 'b', pageSize: 1, maxFindings: 1 }); + assert.equal(first.findings[0].filesId, firstId.toHexString()); + assert.deepEqual(first.resumeAfter, { filesId: firstId.toHexString() }); + const next = await inspectGridFSOrphans({ db, bucket: 'b', pageSize: 1, after: first.resumeAfter }); + assert.deepEqual(next.findings.map((row: { filesId: string }) => row.filesId), [secondId.toHexString()]); + assert.equal(next.truncated, false); + await assert.rejects(inspectGridFSOrphans({ db, bucket: 'b', after: { filesId: 'foreign' } }), { + code: 'GRIDFS_INSPECT_PRECONDITION_FAILED', + }); + assert.equal(await db.collection('b.chunks').countDocuments(), 5); + }); + }); + it('caps findings, streams results, and resumes a large-cardinality scan without deleting', async () => { await withDb(async (db) => { const total = 250; diff --git a/test/integration/gridfs/gridfs-remaining-issues.test.ts b/test/integration/gridfs/gridfs-remaining-issues.test.ts index 16692b5..591cd4e 100644 --- a/test/integration/gridfs/gridfs-remaining-issues.test.ts +++ b/test/integration/gridfs/gridfs-remaining-issues.test.ts @@ -232,6 +232,118 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str }); }); + it('rejects a stale finalize retry that sees a replacement with the same bytes', { timeout: 10_000 }, async () => { + await withRuntime('gridfs_finalize_stale_retry', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const id = new ObjectId(); + const payload = Buffer.from('SAME'); + const opts = { id, length: payload.length, sha256: sha(payload) }; + await bucket.beginResumableUpload('first.bin', opts); + await bucket.appendChunks(id, 0, payload); + + const inserted = gate(); + const reading = gate(); + const releaseFirst = gate(); + const resumeRetry = gate(); + const originalDelete = Collection.prototype.deleteOne; + const originalFind = Collection.prototype.findOne; + let pauseRelease = true; + let pauseRead = false; + Collection.prototype.deleteOne = async function (this: Collection, filter, options) { + if (pauseRelease && this.collectionName === 'b.uploads' && filter?.status === 'finalizing') { + pauseRelease = false; + inserted.open(); + await releaseFirst.wait; + } + return originalDelete.call(this, filter, options); + }; + Collection.prototype.findOne = async function (this: Collection, ...args: unknown[]) { + if (this.collectionName === 'b.files' && pauseRead) { + pauseRead = false; + reading.open(); + await resumeRetry.wait; + } + return Reflect.apply(originalFind, this, args); + } as typeof originalFind; + + const first = bucket.finalizeResumableUpload(id); + const pending = [first]; + try { + await inserted.wait; + pauseRead = true; + const retry = assert.rejects(bucket.finalizeResumableUpload(id), { code: 'GRIDFS_UPLOAD_BUSY' }); + pending.push(retry); + await reading.wait; + releaseFirst.open(); + await first; + await bucket.delete(id); + await bucket.beginResumableUpload('replacement.bin', { ...opts, metadata: { label: 'replacement' } }); + await bucket.appendChunks(id, 0, payload); + await bucket.finalizeResumableUpload(id); + resumeRetry.open(); + await retry; + assert.equal((await bucket.getFile(id))?.filename, 'replacement.bin'); + assert.deepEqual(await bucket.download(id), payload); + assert.equal(await db.collection('b.uploads').findOne({ _id: id }), null); + } finally { + releaseFirst.open(); + resumeRetry.open(); + Collection.prototype.findOne = originalFind; + Collection.prototype.deleteOne = originalDelete; + await Promise.allSettled(pending); + } + }); + }); + + it('bounds missing-chunk diagnostics and counts the complete gap', { timeout: 10_000 }, async () => { + await withRuntime('gridfs_finalize_gap_bound', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 1 }); + const length = 10_000_000_000; + const { uploadId } = await bucket.beginResumableUpload('large.bin', { + length, sha256: sha(Buffer.from('unused')), + }); + await db.collection('b.chunks').insertMany([ + { files_id: uploadId, n: 0, data: Buffer.from('A') }, + { files_id: uploadId, n: length - 2, data: Buffer.from('B') }, + ]); + await assert.rejects(bucket.finalizeResumableUpload(uploadId), (error: unknown) => { + const incomplete = error as { code: string; details: { missingChunks: number[]; missingChunkCount: number }[] }; + assert.equal(incomplete.code, 'GRIDFS_UPLOAD_INCOMPLETE'); + assert.equal(incomplete.details[0].missingChunkCount, length - 2); + assert.deepEqual(incomplete.details[0].missingChunks, Array.from({ length: 1024 }, (_, index) => index + 1)); + return true; + }); + assert.equal((await db.collection('b.uploads').findOne({ _id: uploadId }))?.status, 'pending'); + assert.equal(await db.collection('b.chunks').countDocuments({ files_id: uploadId }), 2); + assert.equal(await db.collection('b.files').countDocuments({ _id: uploadId }), 0); + }); + }); + + it('uses primary reads for visibility checks before orphan cleanup', async () => { + await withRuntime('gridfs_primary_cleanup', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const payload = Buffer.from('LIVE'); + const { id } = await bucket.upload('visible.bin', payload); + const original = Collection.prototype.findOne; + Collection.prototype.findOne = async function (this: Collection, ...args: unknown[]) { + const options = args[1] as { readPreference?: string } | undefined; + // Model a secondary that has not yet observed the file commit. + if (this.collectionName === 'b.files' && options?.readPreference !== 'primary') return null; + return Reflect.apply(original, this, args); + } as typeof original; + try { + await bucket.abortResumableUpload(id); + await assert.rejects(bucket.beginResumableUpload('duplicate.bin', { + id, length: payload.length, sha256: sha(payload), + }), { code: 'GRIDFS_FILE_EXISTS' }); + } finally { + Collection.prototype.findOne = original; + } + assert.equal(await db.collection('b.chunks').countDocuments({ files_id: id }), 1); + assert.deepEqual(await bucket.download(id), payload); + }); + }); + it('keeps the new finalize owner when an older retry completes after ID reuse', { timeout: 10_000 }, async () => { await withRuntime('gridfs_finalize_owner_reuse', async (runtime, db) => { const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); From 2c45ac33807253e7cb777c80b598cbe06b9cbc1a Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 11:40:32 +0800 Subject: [PATCH 11/12] fix(gridfs): require finalize retry to release its owner --- src/capabilities/gridfs/resumable.ts | 3 ++- test/integration/gridfs/gridfs-remaining-issues.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/capabilities/gridfs/resumable.ts b/src/capabilities/gridfs/resumable.ts index 70c4815..e2b6a9f 100644 --- a/src/capabilities/gridfs/resumable.ts +++ b/src/capabilities/gridfs/resumable.ts @@ -410,7 +410,8 @@ async function committedFile(ctx: GridFSContext, uploadId: ObjectId, session: Gr if (index !== chunkCount(session.length, session.chunkSizeBytes) || total !== session.length || hash.digest('hex') !== session.sha256) { throw integrityMismatch('finalizeResumableUpload', ctx.bucketName, { uploadId, expected: session.sha256, actual: 'committed chunks do not match' }); } - await uploads.deleteOne(owner); + const released = await uploads.deleteOne(owner); + if (!released.deletedCount) throw uploadBusy('finalizeResumableUpload', ctx, uploadId); return info; } catch (error) { // GridFS domain errors (the conflict above) pass through unchanged. diff --git a/test/integration/gridfs/gridfs-remaining-issues.test.ts b/test/integration/gridfs/gridfs-remaining-issues.test.ts index 591cd4e..1bdc81e 100644 --- a/test/integration/gridfs/gridfs-remaining-issues.test.ts +++ b/test/integration/gridfs/gridfs-remaining-issues.test.ts @@ -386,7 +386,7 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str const first = bucket.finalizeResumableUpload(id); pending.push(first); await firstCleanup.wait; - const oldRetry = bucket.finalizeResumableUpload(id); + const oldRetry = assert.rejects(bucket.finalizeResumableUpload(id), { code: 'GRIDFS_UPLOAD_BUSY' }); pending.push(oldRetry); await retryCleanup.wait; finishFirst.open(); @@ -402,7 +402,7 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str assert.equal(owner?.status, 'finalizing'); finishRetry.open(); - assert.equal((await oldRetry).filename, 'old.bin'); + await oldRetry; assert.deepEqual(await uploads.findOne({ _id: id }), owner); await assert.rejects(bucket.abortResumableUpload(id), { code: 'GRIDFS_UPLOAD_BUSY' }); From 0504bcc23f26c399a3b78add484973f7e2e9df03 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 12:34:33 +0800 Subject: [PATCH 12/12] fix(gridfs): bind pending transitions to the observed session --- config/package-budget.json | 4 +- .../maintenance/gridfs-inspect-orphans.cjs | 3 +- src/capabilities/gridfs/resumable.ts | 14 ++++- src/capabilities/gridfs/types.ts | 1 + .../gridfs/gridfs-inspect-orphans.test.ts | 25 ++++++++ .../gridfs/gridfs-remaining-issues.test.ts | 57 +++++++++++++++++++ 6 files changed, 98 insertions(+), 6 deletions(-) diff --git a/config/package-budget.json b/config/package-budget.json index 44862d0..c46e87d 100644 --- a/config/package-budget.json +++ b/config/package-budget.json @@ -1,8 +1,8 @@ { - "comment": "GridFS ownership checks, primary reads and bounded diagnostics bring the 2026-11-13 build to 897375 CJS and 894610 ESM bytes, with no dependency change. Release preflight enforces these ceilings.", + "comment": "GridFS session identity checks bring the 2026-11-13 build to 897830 CJS and 895065 ESM bytes, with no dependency change. Release preflight enforces these ceilings.", "artifacts": { "dist/cjs/index.cjs": 898000, - "dist/esm/index.mjs": 895000, + "dist/esm/index.mjs": 895500, "dist/cjs/cli/data-task.cjs": 50000 } } diff --git a/scripts/maintenance/gridfs-inspect-orphans.cjs b/scripts/maintenance/gridfs-inspect-orphans.cjs index 4c70e96..bb974b5 100644 --- a/scripts/maintenance/gridfs-inspect-orphans.cjs +++ b/scripts/maintenance/gridfs-inspect-orphans.cjs @@ -170,6 +170,7 @@ async function main() { fail('--max-findings must be a positive integer'); } const afterFilesId = readArg(args, 'after-files-id'); + const after = afterFilesId === undefined ? undefined : { filesId: parseAfter({ filesId: afterFilesId }) }; const client = new MongoClient(uri); await client.connect(); try { @@ -178,7 +179,7 @@ async function main() { bucket, pageSize, maxFindings, - after: afterFilesId ? { filesId: afterFilesId } : undefined, + after, collectFindings: false, onFinding: (finding) => { process.stdout.write(JSON.stringify({ type: 'finding', ...finding }) + '\n'); diff --git a/src/capabilities/gridfs/resumable.ts b/src/capabilities/gridfs/resumable.ts index e2b6a9f..a15694e 100644 --- a/src/capabilities/gridfs/resumable.ts +++ b/src/capabilities/gridfs/resumable.ts @@ -54,6 +54,7 @@ export async function beginResumableUpload(ctx: GridFSContext, filename: string, try { await uploads.insertOne({ ...initializing, + sessionToken: ownerToken, filename, length: opts.length, sha256: opts.sha256, @@ -213,7 +214,7 @@ export async function finalizeResumableUpload(ctx: GridFSContext, uploadId: Obje const ownerToken = randomUUID(); const owner = finalizingOwner(uploadId, ownerToken); const transitioned = await uploads.updateOne( - { _id: uploadId, status: 'pending', activeAppend: { $exists: false } }, + { ...sessionIdentity(session), status: 'pending', activeAppend: { $exists: false } }, { $set: { status: 'finalizing', ownerToken, updatedAt: new Date() }, $unset: { expiresAt: '' } }, ); if (!transitioned.matchedCount) throw uploadBusy('finalizeResumableUpload', ctx, uploadId); @@ -348,8 +349,9 @@ export async function abortResumableUpload(ctx: GridFSContext, uploadId: ObjectI if (session.kind && session.kind !== 'resumable') throw uploadBusy('abortResumableUpload', ctx, uploadId); if (session.status === 'finalized') return; if (session.status !== 'pending') throw uploadBusy('abortResumableUpload', ctx, uploadId); + const identity = sessionIdentity(session); const transitioned = await uploads.updateOne( - { _id: uploadId, status: 'pending', activeAppend: { $exists: false } }, + { ...identity, status: 'pending', activeAppend: { $exists: false } }, { $set: { status: 'aborting', updatedAt: new Date() }, $unset: { expiresAt: '' } }, ); if (!transitioned.matchedCount) throw uploadBusy('abortResumableUpload', ctx, uploadId); @@ -357,12 +359,18 @@ export async function abortResumableUpload(ctx: GridFSContext, uploadId: ObjectI // aborting record that prevents a second writer from claiming this ID. try { await removeInvisibleChunks(ctx, uploadId, 'abortResumableUpload'); - await uploads.deleteOne({ _id: uploadId, status: 'aborting' }); + await uploads.deleteOne({ ...identity, status: 'aborting' }); } catch (error) { throw mapDriverError(error, 'abortResumableUpload', ctx, { uploadId }); } } +function sessionIdentity(session: GridFSUploadSession) { + if (session.sessionToken !== undefined) return { _id: session._id, sessionToken: session.sessionToken }; + // Older sessions have no token. In particular, they must never match a new upload. + return { _id: session._id, sessionToken: { $exists: false }, createdAt: session.createdAt ?? { $exists: false } }; +} + function finalizingOwner(uploadId: ObjectId, ownerToken?: string) { // Legacy finalizing records have no token. Their retries can only clear legacy records. return { _id: uploadId, status: 'finalizing', ownerToken: ownerToken ?? { $exists: false } }; diff --git a/src/capabilities/gridfs/types.ts b/src/capabilities/gridfs/types.ts index 4c4cfa4..411acd1 100644 --- a/src/capabilities/gridfs/types.ts +++ b/src/capabilities/gridfs/types.ts @@ -86,6 +86,7 @@ export interface GridFSUploadSession { contentType?: string; kind?: 'resumable'; status: 'initializing' | 'pending' | 'finalizing' | 'aborting' | 'finalized' | 'aborted'; + sessionToken?: string; ownerToken?: string; activeAppend?: { token: string; startedAt: Date }; expiresAt?: Date; diff --git a/test/integration/gridfs/gridfs-inspect-orphans.test.ts b/test/integration/gridfs/gridfs-inspect-orphans.test.ts index f46d4ac..7e58b81 100644 --- a/test/integration/gridfs/gridfs-inspect-orphans.test.ts +++ b/test/integration/gridfs/gridfs-inspect-orphans.test.ts @@ -1,5 +1,7 @@ import { after, before, describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import { Collection, MongoClient, ObjectId, type Db } from 'mongodb'; import { createMemoryServerBootstrap } from '../../bootstrap/memory-server'; @@ -31,6 +33,29 @@ describe('GridFS inspect-only orphan reporter', () => { } } + it('rejects an explicitly empty CLI continuation but accepts an omitted or valid one', { timeout: 15_000 }, async () => { + await withDb(async (db) => { + const first = new ObjectId(); + const second = new ObjectId(); + await db.collection('b.chunks').insertMany([{ files_id: first, n: 0 }, { files_id: second, n: 0 }]); + const runCli = (...args: string[]) => promisify(execFile)(process.execPath, [ + 'scripts/maintenance/gridfs-inspect-orphans.cjs', `--db=${db.databaseName}`, '--bucket=b', ...args, + ], { env: { ...process.env, MONSQLIZE_RECOVERY_MONGODB_URI: uri } }); + + await assert.rejects(runCli('--after-files-id='), { + code: 1, stdout: '', stderr: 'GRIDFS_INSPECT_PRECONDITION_FAILED: after.filesId must be an ObjectId\n', + }); + const { stdout } = await runCli('--max-findings=1'); + const [finding, summary] = stdout.trim().split('\n').map((line) => JSON.parse(line)); + assert.equal(finding.filesId, first.toHexString()); + assert.equal(summary.resumeAfter.filesId, first.toHexString()); + const continued = await runCli(`--after-files-id=${summary.resumeAfter.filesId}`); + const [next, end] = continued.stdout.trim().split('\n').map((line) => JSON.parse(line)); + assert.equal(next.filesId, second.toHexString()); + assert.equal(end.truncated, false); + }); + }); + it('classifies visible, owned, and candidate orphan files_id groups without deleting', async () => { await withDb(async (db) => { const visible = new ObjectId(); diff --git a/test/integration/gridfs/gridfs-remaining-issues.test.ts b/test/integration/gridfs/gridfs-remaining-issues.test.ts index 1bdc81e..dae32ae 100644 --- a/test/integration/gridfs/gridfs-remaining-issues.test.ts +++ b/test/integration/gridfs/gridfs-remaining-issues.test.ts @@ -295,6 +295,63 @@ describe('GridFS remaining-issue regressions (begin race, finalize rollback, str }); }); + for (const operation of ['finalizeResumableUpload', 'abortResumableUpload'] as const) { + for (const legacy of [false, true]) { + it(`${operation} does not take over a replacement pending session after reading a ${legacy ? 'legacy' : 'tokenized'} session`, { timeout: 10_000 }, async () => { + await withRuntime('gridfs_pending_session_reuse', async (runtime, db) => { + const bucket = runtime.gridfs('b', { chunkSizeBytes: 4 }); + const id = new ObjectId(); + const payload = Buffer.from('DATA'); + const options = { id, length: payload.length, sha256: sha(payload) }; + await bucket.beginResumableUpload('old.bin', options); + await bucket.appendChunks(id, 0, payload); + const uploads = db.collection('b.uploads'); + if (legacy) await uploads.updateOne({ _id: id }, { $unset: { sessionToken: '' } }); + const oldSession = await uploads.findOne({ _id: id }); + assert.ok(oldSession?.createdAt instanceof Date); + + const claiming = gate(); + const resume = gate(); + const original = Collection.prototype.updateOne; + const status = operation === 'finalizeResumableUpload' ? 'finalizing' : 'aborting'; + let pause = true; + Collection.prototype.updateOne = async function (this: Collection, filter, update, opts) { + if (pause && this.collectionName === 'b.uploads' && id.equals(filter?._id) && + !Array.isArray(update) && update.$set?.status === status) { + pause = false; + claiming.open(); + await resume.wait; + } + return original.call(this, filter, update, opts); + }; + + const stale = bucket[operation](id); + try { + await claiming.wait; + await bucket.abortResumableUpload(id); + await bucket.beginResumableUpload('new.bin', options); + await bucket.appendChunks(id, 0, payload); + await uploads.updateOne({ _id: id }, { $set: { createdAt: oldSession.createdAt } }); + const replacement = await uploads.findOne({ _id: id }); + assert.equal(replacement?.status, 'pending'); + + resume.open(); + await assert.rejects(stale, { code: 'GRIDFS_UPLOAD_BUSY' }); + assert.deepEqual(await uploads.findOne({ _id: id }), replacement); + assert.equal(await db.collection('b.chunks').countDocuments({ files_id: id }), 1); + assert.equal(await bucket.getFile(id), null); + assert.equal((await bucket.finalizeResumableUpload(id)).filename, 'new.bin'); + assert.deepEqual(await bucket.download(id), payload); + } finally { + resume.open(); + Collection.prototype.updateOne = original; + await Promise.allSettled([stale]); + } + }); + }); + } + } + it('bounds missing-chunk diagnostics and counts the complete gap', { timeout: 10_000 }, async () => { await withRuntime('gridfs_finalize_gap_bound', async (runtime, db) => { const bucket = runtime.gridfs('b', { chunkSizeBytes: 1 });