diff --git a/src/core/folder-counts.ts b/src/core/folder-counts.ts index fd5d4c0..ee62754 100644 --- a/src/core/folder-counts.ts +++ b/src/core/folder-counts.ts @@ -22,7 +22,7 @@ export async function folderCounts(repo: Repository, requestedPath: unknown): Pr const prefix = originalPath ? originalPath + "/" : ""; const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const childDepth = originalPath ? originalPath.split("/").length : 0; - const rows = await FileModel.aggregate<{ _id: string; count: number }>([ + const aggregate = () => FileModel.aggregate<{ _id: string; count: number }>([ { $match: { repoId: repo.repoId, treeGeneration: repo.model.treeGeneration || { $exists: false }, @@ -35,7 +35,19 @@ export async function folderCounts(repo: Repository, requestedPath: unknown): Pr { $arrayElemAt: [{ $split: ["$path", "/"] }, childDepth] }, ] } : { $arrayElemAt: [{ $split: ["$path", "/"] }, 0] }, count: { $sum: 1 } } }, { $limit: FILE_LISTING_LIMIT + 1 }, - ]).option({ maxTimeMS: 2000, allowDiskUse: true }).exec(); + ]).option({ maxTimeMS: 2000, allowDiskUse: true }); + let rows: { _id: string; count: number }[]; + try { + // The short planner trial can favor the size-first index despite its + // document fetches. Legacy snapshots still use the default planner. + rows = await aggregate().option(repo.model.treeGeneration ? { hint: "file_counts_covering" } : {}).exec(); + } catch (error) { + // During an additive rollout the index may not be ready yet. Retry only + // the missing-hint planning error, never timeouts or execution failures. + if (!repo.model.treeGeneration || !(error instanceof Error) || !("code" in error) || error.code !== 2 || + !/hint provided does not correspond to an existing index/.test(error.message || "")) throw error; + rows = await aggregate().exec(); + } if (rows.length > FILE_LISTING_LIMIT) throw new AnonymousError("file_listing_too_large", { httpStatus: 413 }); const counts: Record = Object.create(null); const compiled = compileTerms(repo.options.terms || []); diff --git a/test/folder-counts.test.js b/test/folder-counts.test.js index 0a1d90d..52a212c 100644 --- a/test/folder-counts.test.js +++ b/test/folder-counts.test.js @@ -17,6 +17,22 @@ describe("folder count request validation", () => { expect(error.message).to.equal("invalid_path"); expect(error.httpStatus).to.equal(400); }); } + for (const error of [Object.assign(Error("operation exceeded time limit"), { code: 50 }), + Object.assign(Error("unrelated bad value"), { code: 2 })]) { + it(`does not retry ${error.message}`, async () => { + const original = File.aggregate; let calls = 0; + File.aggregate = () => { + calls++; + return { option() { return this; }, exec: async () => { throw error; } }; + }; + try { + const caught = await folderCounts({ repoId: "fixture", model: { treeGeneration: "active" } }, "") + .then(() => null, error => error); + expect(caught).to.equal(error); expect(calls).to.equal(1); + } finally { File.aggregate = original; } + }); + } + }); (uri ? describe : describe.skip)("scoped folder counts with MongoDB", function () { @@ -50,17 +66,31 @@ describe("folder count request validation", () => { await insert(Array.from({ length: 2000 }, (_, i) => ({ name: `file${i}`, path: `src/folder${i % 20}`, size: i % 2 }))); await insert([{ name: "excluded-directory", path: "" }, { name: "old", path: "old", size: 1, treeGeneration: "retired" }]); - let pipeline; + let pipeline, query; const aggregate = File.aggregate; - File.aggregate = function (stages, ...args) { pipeline = stages; return aggregate.call(this, stages, ...args); }; + File.aggregate = function (stages, ...args) { + pipeline = stages; query = aggregate.call(this, stages, ...args); return query; + }; try { expect(await folderCounts(repo, "")).to.deep.equal({ "": 2000, src: 2000 }); } finally { File.aggregate = aggregate; } - const explained = await File.aggregate(pipeline).option({ hint: "file_counts_covering" }).explain("executionStats"); + expect(query.options.hint).to.equal("file_counts_covering"); + const explained = await File.aggregate(pipeline).option(query.options).explain("executionStats"); const cursor = explained.stages?.find(stage => stage.$cursor)?.$cursor || explained; expect(cursor.executionStats.totalDocsExamined).to.equal(0); expect(cursor.executionStats.totalKeysExamined).to.be.at.least(2000); }); + it("falls back before the covering index exists and uses it after creation", async () => { + await insert([{ name: "one", path: "src", size: 1 }]); + await File.collection.dropIndex("file_counts_covering"); + try { + expect(await folderCounts(repo, "")).to.deep.equal({ "": 1, src: 1 }); + } finally { + await File.collection.createIndex({ repoId: 1, treeGeneration: 1, path: 1, size: 1 }, { name: "file_counts_covering" }); + } + expect(await folderCounts(repo, "")).to.deep.equal({ "": 1, src: 1 }); + }); + it("preserves counts for legacy repositories without a generation", async () => { repo.model.treeGeneration = undefined; await repo.model.save(); await File.collection.insertMany([