Content indexing coverage

The rule: every file in a content collection is either fully indexed — all of its text, chunked whole — or recorded with the reason it is not. A file the indexer cannot read still gets its Document node ({collection}/_Documents/…), carrying indexingGap (the reason) and chunkCount: 0, and the same sentence is on the indexing activity's log line: 'decks/q3.pptx': NoText (0 chunk(s)) — no text extractor for '.pptx' files.

Find every file the index cannot serve with a search for nodeType:Document under the collection and read content.indexingGap.

This page is the audit behind that rule: it was measured against the code of src/MeshWeaver.ContentCollections.Indexing* (this repository) and MeshWeaver.ContentCollections (core), not against other documentation. The flow itself is Document ingestion for attached storage, and the upload → ContentIndexingObserver → ContentIndexingService.IndexFile path for content collections.

Formats

Format Extracted by Complete?
.pdf PdfPig, word by word, every page (TextExtractor.ExtractPdf) The whole text LAYER. A scanned PDF has none and yields almost nothing — OCR (IOcrProvider) is being added beside the Excel/zip extractors; until it lands such a PDF indexes the little text it has, or is recorded as having none.
.docx DocSharp, converted to Markdown (TextExtractor.ExtractDocx) Whole document.
.md .markdown .txt .csv .json .html .htm .xml UTF-8 decode (BOM honoured) Whole file, as text.
.png .jpg .jpeg .gif .webp .bmp .tif .tiff IImageDescriber (ChatClientImageDescriber) when one is wired One model description, used as both the indexed text and the summary. Without a describer, the image's own text is read by the OCR provider; with neither: recorded no OCR provider is registered to read '.png' images. An empty description: recorded the image description returned no text. A FAILED describe (the model call or the client resolution throws) is a failed extraction, never an empty description: ChatClientImageDescriber lets the error through, so a vision outage during a forced rebuild keeps the earlier index instead of erasing it (ForcedRebuild_DuringAVisionOutage_KeepsTheEarlierIndex). Only "no vision model configured" answers empty.
.xlsx .xlsm .xls .zip Not on main; the extractors are in review in MeshWeaver.Plugins#3826 Today: recorded as no text extractor for '.xlsx' files.
everything else (.pptx, .eml, .msg, .rtf, .odt, and plain-text formats outside the list — .yaml, .log, .cs, .py, .tsv, …) none Recorded, never indexed: no text extractor for '{ext}' files. Plain-text formats outside the extension list are an open gap (below).

Caps

Cap Where Truncates the index?
Chunk window 1,000 characters, overlap 150 (ContentIndexingOptions) TextChunker.Windows No — the windows cover the whole text, the last one runs to the end.
Summary input 12,000 characters (ChatClientSummarizer.MaxTextChars) the Document summary only No — the summary is written from the head of the text; the chunks and parts carry all of it.
Extractive summary 280 characters ExtractiveSummarizer No (summary only).
Storage-ingestion preview 4,000 characters (IngestDocument.MaxText) IngestDocumentNodeType.Record No — a display preview; the parts carry all of it.
Storage-ingestion original 256 MB (IngestionRuns.MaxOriginalBytes) IngestionRuns.Walk The file is not ingested. It is now named with the reason on the connection's log line (…, 1 skipped, not ingested: video/all-hands.mp4 (… bytes, over the … limit)); before, the line said only "n skipped (unreadable — see the log)".
Embedding input EmbeddingProviderChunkEmbedder, the embedding providers No cap: each chunk is embedded whole.
Part writes in flight per document: 4 (MeshDocumentSink.MaxConcurrentPartWrites, and the same 4 in DocumentPartWriter and IngestDocumentNodeType) every _DocumentPart create of one document No — a throughput bound; every part is still written. Each part write is a COLD IMeshService.CreateOrUpdateNode, posted only when the bounded merge subscribes it. The upload sink used to build them with hub.Observe(…), which posts at the CALL, so its .ToArray() sent every part at once and the bound limited only the awaited answers: a zip extracted to ~9,400 windows put ~9,400 creates on the node-operations hub together and 7,875 reached no verdict within their 30 s deadline (MeshWeaver#7464).
Extraction bounds of #3826 (TextExtractionLimits: MaxTextLength 8,000,000 characters, archive entries/bytes/directory records, workbook bytes, scanned cells, nesting depth) #3826's extractors These are DoS bounds and stay. On that branch a bound is reported as a note INSIDE the extracted text ([truncated: …], [not extracted: …]) and in the pod log — not as a recorded status. ExtractedDocument.IndexingGap is the seam it should set so the bound reaches the Document (open item below).

Skips — where a file is not indexed, and where that is recorded

Case Before Now
No extractor for the format NoText, one activity-log line naming no reason, no Document Document with indexingGap: no text extractor for '{ext}' files, chunkCount: 0; the log line names the reason.
Extraction throws (a corrupt PDF, a model that is down) Activity Failed and the admin notification; the PREVIOUS version's chunks and Document stayed, describing other bytes Same failure, and the file is recorded: its chunks are removed and its Document says text extraction failed: …. A forced rebuild of UNCHANGED bytes that fails keeps the earlier good index (nothing is rewritten).
The file has no text (empty, whitespace) NoText, no Document; a file that HAD text kept its old summary and parts Document with indexingGap: no text could be extracted…, no summary, its old parts pruned.
A model call TIMES OUT (vision describer, summarizer) An HTTP timeout throws TaskCanceledException with nobody having cancelled anything; the indexer rethrew it unrecorded as a cancellation and the re-index walk ended Cancelled at that file The leaf classifies it (IoLeafTimeout): an OperationCanceledException while the pool's token is live is a TimeoutException, so the file is recorded like any failed extraction (text extraction failed: … did not complete) and the walk goes on to the next file. A real cancel (the pool's token cancelled) still ends the walk Cancelled.
The SUMMARY fails or times out after the chunks were written The failure went to the caller unrecorded; the earlier version's Document (its summary, hash and chunk count) stood beside the new chunks under a settled hash, so no later index retried it The Document is written with no summary and indexingGap: the summary could not be written: …, the gate hash carries +summary-retry (never settled, so the next ordinary index retries), and the failure still reaches the caller.
Part of the file read, part not (the extractor returns text AND a reason) not covered by a test The text is indexed, and the reason is on both IndexResult.Gap and the Document's indexingGap.
A sink but NO summarizer (AddContentIndexingPipeline without a summarizerFactory; production always wires ExtractiveSummarizer) No Document was written for a readable file — so a file first recorded with a gap kept that gap Document, its old hash and chunkCount: 0 beside its current chunks The Document is written with an EMPTY summary: the sink, not the summarizer, decides whether a Document exists.
Unchanged bytes Hash gate: Skipped, nothing re-extracted (the missing-Document heal still runs) unchanged
File gone before its activity read it activity log warning unchanged (nothing to index)
Indexing inactive on the deployment (no embedding provider / vector database) inert by design: uploads proceed, nothing indexes (ContentIndexingPipelineExtensions.InactiveReason); but RESOLVING the reindex entry point (ContentIndexingObserver) threw that reason, and since core builds every shared singleton of a module before publishing it, the throw withdrew the whole indexing module at every boot, reported as an IncompatibleModule (MeshWeaver#7462) still a configuration state, stated on the Content Indexing settings tab. The observer is BUILT inactive (IsActive == false): every indexing entry point (ReindexAll, IndexFileActivity, IndexFilesActivity, IndexWrittenFiles, PendingFiles) answers InvalidOperationException with InactiveReason, and the upload, delete and bulk-write hooks index nothing. A shared constructor never throws for "off".
Storage ingestion: file over 256 MB, unreadable, read timed out counted, names only in the pod log named with the reason on the connection's log
Storage ingestion: no extractable text recorded on the IngestDocument (error) unchanged

Which writes reach the pipeline

The pipeline indexes a file when it is ANNOUNCED. A single upload is announced per file (IContentUploadObserver.OnUploaded, raised by the caller). A write that puts many files there at once is announced ONCE (IContentUploadObserver.OnFilesWritten, raised once the write committed — by the owning collection, or for a sync by its producer after the last delivery answered — under the writer's identity — core MeshWeaver#7190, Doc/Architecture/ContentIndexingActivation → "What a bulk write announces"). Deletes are raised by ContentCollection itself for every delete path. A write path that announces nothing is indexed only when the daily backfill (below) walks its collection.

Write path Announced?
MeshOperations.Upload (MCP upload, the API) yes, per file
Executive Assistant SaveAttachment yes — it writes through MeshOperations.Upload
Mail-copy attachments ({copy}/attachments, MeshMailAttachmentStore.Save and the legacy move) yes; the index follows the copy node and its delete removes it
File browser upload (FileBrowser) yes, per file
Collection→collection import (ImportContentRequest) yes, ONCE per import, with every file it copied
Repository/GitSync content mirror (SyncContentFilesRequest) yes, ONCE per sync — the producer (SyncContentFilesBuilder.Post) announces every file it carried once its last delivery answered success, however many deliveries it split the sync into
IFileContentProvider.SaveFileContent (AI file writes, data-extension writes) yes, once per write
Node pictures (NodeImageUpload.Replace) yes, once per new picture (every upload is a fresh generated name)
Staging blobs, a streamed document's original (DocumentPartWriter) no, deliberately: internal files

One announcement is at most ONE indexing activity. ContentIndexingObserver.OnFilesWritten queues the write on a serial pump; its gate (FilesToIndex) reads each named file and keeps only those whose bytes the index does not already serve. A file counts as served when its stored hash matches AND, where the document branch writes a Document for it, that Document exists (ContentIndexingService.Serves), or when its Document records a gap for exactly these bytes (one listing of {collection}/_Documents). Current chunks with a MISSING Document (a Document write that failed after the chunks were committed, or an index older than the document branch) stay pending, so IndexFile's hash-gate repair writes the Document. The kept files are indexed as one activity (IndexFilesActivity); when none are kept, NO activity opens. So a boot re-import, which re-sends every content file of every synced repository, opens an activity only for a repository whose content actually changed. The serial pump means two announcements never index at the same time. That is the opposite of the per-file burst Doc/Architecture/ContentRoute503 (core, Cause B) describes, and it is why a sync is never announced per file.

Every file the activity indexes is looked up AGAIN once its chunks are committed, and on a late failure too; when it was deleted meanwhile its index is removed once more, and when it now holds DIFFERENT bytes (a newer version was written and indexed while this run held the old one) the current bytes are indexed again, so a late commit of the old version never stays in the index. The single upload, the bulk write, the re-index and the backfill share that one per-file step, so a delete or a rewrite that lands while a sync is still extracting or embedding never leaves the deleted file, or the superseded version, searchable. Bulk writes are indexed one batch at a time, and a batch that fails does not stop the batches behind it.

A write whose file the index cannot read is still recorded (indexingGap) and does not fail the activity. A gap recorded for unchanged bytes is not re-attempted on the next write. The daily backfill retries it.

Re-index and backfill

ContentIndexingObserver.ReindexAll walks every file of the named collections as one cancellable activity and runs IndexFile for each. It is hash-gated, so a run indexes exactly the files the index does not hold yet — which is the backfill for files uploaded before indexing existed — and re-attempts every recorded gap (a recorded file has no stored hash). force re-extracts everything.

It is reached from the Space settings tab Content Indexing (Re-index all content, Rebuild), for the Space's own content collection only.

The daily backfill

ContentIndexBackfill.Run is the fleet-wide, hash-gated backfill. It enumerates every Space (nodeType:Space partitions:all, giving {space}/content) and every mail copy that carries attachments (nodeType:MailMessageCopy content.hasAttachments:true partitions:all, giving {copy}/attachments), page by page in path order. It then visits the collections one at a time:

The run reports one line per collection: current, reindexed (with the pending count and the activity), no-collection, or failed (with the reason). The line follows the re-index activity's TERMINAL status: an activity that ended Failed is a failed line carrying its activity and reason, and one that was Cancelled stops the run. Collection paths are de-duplicated ordinally: mesh paths are case-sensitive, so two paths that differ only by case are two collections. When an enumeration reaches its page bound, the report says TRUNCATED instead of reading as complete.

Authorized as the caller, executed as System (policy authorize-as-caller-execute-as-system). The caller is captured when Run is called. The platform (System, or trusted infrastructure that set no context) passes. An anonymous context (an empty ObjectId) is refused before anything is read. A person must be a global admin, checked before the enumeration is subscribed, and an unanswered check fails closed. The run itself executes as System on behalf of that person, because it reads every partition.

The operation. The Indexing/Script/content-index-backfill Code node (Run button; its activity lands under Admin) runs ContentIndexBackfill.Run(Mesh), so a cancel is the activity's RequestedStatus. It reaches it BY KEY: GetRequiredKeyedService<Func<IMessageHub, IObservable<string>>>("content-index-backfill"), registered with the pipeline. A Code node compiles against the portal image's reference set, which a host type added after that image does not belong to, so a script that named ContentIndexBackfill failed CS0103 in the Code-node gate. A delegate of framework types resolves on every image. A cancel that disposes a running re-index is recorded: the child activity ends Cancelled and never stays Running.

The enumeration visits nodeType:(Space OR Store/Plugin), the package roots Store/Catalog lists, plus the mail copies, plus every collection holding a Document that records a gap (nodeType:Document content.indexingGap:* partitions:all; the collection is the Document path before /_Documents/). The third enumeration reaches a gap wherever it was recorded, such as a node picture in {node}/content on a node that is neither a Space nor a package root. It pages by KEYSET (sort:path + path:>"{last}"), never by offset. The Postgres fan-out loads Limit rows and the merge layer applies an offset afterwards, so offset page two came back empty. One unreadable file neither stops the gate nor the walk: it is attempted and recorded FAILED while the readable files are indexed. The recorded-gap listing filters for gaps IN the query, so its limit cannot be spent on indexed Documents.

The daily schedule is the platform's repeating EventSubscription, Admin/EventSubscription/content-index-backfill: a Timer firing at 02:00 UTC, RepeatEvery one day, with a RunScript continuation on that Code node, which EventSubscriptionRunner fires as System. ContentIndexBackfillSchedule, a hosted service the PostgreSQL module registers behind the same activation gate as the pipeline, CREATES that subscription once. It never overwrites it: a restart does not reset its next slot, and an administrator's edit or cancellation stands. Because the schedule outlives a switch-off, the keyed backfill delegate checks the same activation gate first: on a deployment whose indexing is not active it answers Content index backfill: SKIPPED — … and never fails.

Open

Proven

Negative control: with the recording reverted, five of the unit cases, the node case and the re-index case fail — the re-index log then reads 'decks/q3.pptx': NoText (0 chunk(s)). and no node is written.