Repository navigation
No file-series support in the cloud path: a downloaded series' members are unreachable #215
Description
Found while burning down the signature-drifted bridge entries in #214. Recorded there in updateFileInfoForLocalFiles's decision_log, but it is bigger than one entry and belongs in its own issue.
What is missing
Nothing in src/ndi/ reads files.series_info, files.file_series or n_present. A file series — a document declaring many member files behind one manifest — has no representation in the Python cloud path at all.
MATLAB has this, and has been fixing it recently:
| MATLAB commit | What it does |
|---|---|
045c0eef4 (NDI-matlab#945) |
Carries files.series_info across reset_file_info, which was dropping the per-series record so a populated series reported zero members |
cd3c11673 (NDI-matlab#958) |
Adds a cloudDatasetId argument that reconstructs a downloaded series' ingest_locations from the manifest, one entry per present member |
504c5bcf4 (NDI-matlab#966) |
Keeps the manifest's ndic:// cloud reference beside the local copy |
Why the last one matters most
The members of a downloaded series are deliberately left on the cloud — the point is that opening a dataset does not drag down a 28,000-member series. A member has no location of its own; DID resolves one by handing the manifest's location to the custom file handler with the member's uid in the context.
So the manifest's ndic:// reference is the only thing telling the handler where to look. With the local path alone, download_file_from_cloud gets a path that does not start with ndic://, raises UnsupportedFileLocationType, and DID reads that as a plain miss. Every member of a downloaded series is then unreadable, silently.
That is MATLAB's failure mode. Python does not hit it today only because it never builds a series in the first place.
Also relevant: DID's guard
DID-matlab#185 added a MembersNotLocatable guard that refuses a document whose series records n_present > 0 but records no way to locate any of them — exactly the shape a cloud round trip produces, since ingest_locations is stripped at store time. Worth checking whether DID-python carries the same guard, because if it does, the first Python document with a populated series will be refused on add_docs rather than merely mis-resolved.
What Python has now
src/ndi/cloud/filehandler.py handles single files correctly: ndic:// URIs, updateFileInfoForLocalFiles rewriting each file_info location to a local path. It is the per-member indirection that is absent — there is no manifest, no member enumeration, and no second location on a manifest.
The two implementations have also drifted structurally, which is worth knowing before porting: MATLAB's updateFileInfoForLocalFiles calls document.reset_file_info() and rebuilds, so its #945 fix is about surviving that reset. Python edits the properties dict in place and never resets, so #945 has no Python counterpart — but that also means the Python function is not shaped to carry a series record through, and will need more than a transliteration.
Scope
This is a feature port, not a drift fix, which is why #214 does not attempt it. Roughly:
- Decide whether series belong in NDI-python at all right now, or whether the representation belongs one layer down in DID-python (
files.series_infois a DID document concept — seeAGENTS.md§4 on layered dependencies). - Read and write
files.series_infothrough the cloud round trip. - Reconstruct
ingest_locationson download, withndic://locations built from the dataset id. - Keep the manifest's cloud reference alongside its local copy.
- Check DID-python for the
MembersNotLocatableguard.
Step 1 is the real decision and wants a maintainer, not a porter.
Activity
claude commentedon Sep 8, 2026
Step 1 answered: the representation belongs in DID-python, and it is already there
I cloned DID-python (
7e92695) to check.did.documentmodels file series in full, and it landed a day before this issue was written — commitf292b16, "Port DID-matlab #173 file series + per-uid fetch lock; refresh bridge", merged as DID-python#65. The bridge entries are datedPORTED 2026-09-07.did.documentseries_names,is_file_series,series_member_of,is_series_member,series_count→(n, n_present),series_source_root,series_ingest_locations,add_file_series,remove_file_series,strip_series_ingest_locations— reading and writingfiles.file_series,files.series_info,n_presentdid.fileread_series_manifest,read_series_manifest_uid,write_series_manifest— the shared binary wire formatdid.databaseseries_has,series_members,_series_manifest_pathdid.implementations.sqlitedb_ingest_series_memberson add,_open_series_memberon read,_manifest_locations_for_handler, and the DID-matlab#191 guard against a handler that returns the manifest's own bytesSo the fork in step 1 resolves cleanly, and it resolves the way
AGENTS.md§4 says it should: NDI-python must model no part offiles.series_infoitself. Notseries_info, notn_present, not the manifest format, not member enumeration. All of it goes throughdid.What steps 2–4 describe is not representation, though — it is the cloud round trip, and that is genuinely NDI's layer. DID is explicit that it stays out of it:
_open_series_member's docstring says "DID composes no URL and learns no scheme", andndic://is NDI's scheme. So this issue is cleared for NDI-python, with one precondition and one prerequisite that is larger than the issue assumes. Both below.reset_file_infoalso has no Python counterpart to worry aboutdid.document._reset_file_infoclearsseries_infoalongsidefile_info, same as MATLAB — so MATLAB's #945/#946 hazard is real in Python too, for any code that calls it. Python'supdateFileInfoForLocalFilesdoesn't, which is why the bridgedecision_logsays #945 has nothing to fix here. That stays true. It is not evidence that the Python shape is fine; see below.The guard: yes, DID-python carries it
sqlitedb._reject_series_without_ingest_locations(src/did/implementations/sqlitedb.py:1522), called fromdo_add_docfor a document new to the database, before any write:So the issue's suspicion is confirmed, and it matters exactly as much as it says:
ingest_locationsis stripped at store time bystrip_series_ingest_locations, so the first Python document with a populated series is refused atadd_docsoutright, not merely mis-resolved. It is aValueError, not a warning. Any download path that reconstructsingest_locationsis not an optimisation — it is what makes the add possible at all.The precondition: DID-python's series API is implemented but untested
This is the one thing I would not build on without saying out loud. DID-python's suite passes (
522 passed, 8 skipped, 1 xfailed), but run it under coverage and look at the series functions — statements covered / total:did.file's manifest codec is genuinely covered — bytests/symmetry/make_artifacts/file/test_series_manifest.py, which writes five manifests (dense, sparse, named, allEmpty, wideUid) and reads them back. Everything above the codec is at "thedefline ran and nothing else did".1/54and1/53mean those bodies never execute;grep -rn "add_file_series\|series_count\|series_has" tests/returns nothing.One sentence: no test in DID-python ever constructs a document with a populated series. The bytes are proven; every behaviour built on them is not — including the
MembersNotLocatableguard that NDI's download path exists to satisfy.Filed one layer down rather than worked around here, per §4. A DID-python session is opening that issue and writing the tests; I will link it here when it lands.
What NDI-python would need
Four things, and one of them is not in the issue's list.
A. The handler must accept
context— this is thendic://problem in its Python form.src/ndi/database.py:_cloud_file_handler(dest_path, source_path)is what DID actually receives, andsrc/ndi/cloud/filehandler.py:download_file_from_cloud(dest_path, source_path, client=None)is what it calls. Both are two-argument.DID's
_dispatch_custom_file_handlerinspects the signature and calls with three arguments only when the handler declares three positional parameters. So NDI's handler is called with two — and for a series member,source_pathis the manifest's location. Parsing the uid out of it fetches the manifest and stores it under the member's uid.The Python failure is not silent in the way the issue describes MATLAB's, but it is not better: DID-python's #191 guard compares the fetched size against the manifest it already holds and raises
DID:SQLITEDB:FileSeries:HandlerReturnedManifest, so every member read fails with an error about the wrong thing. (And the guard is skipped whenos.path.getsize(manifest_path)raises, in which case the manifest bytes are cached under the member's uid and every later read of that member returns the manifest, quietly and forever.)The fix is the same as MATLAB's: widen both functions to
(dest_path, source_path, context)and take the uid fromcontext["uid"]only whencontext["seriesName"]is non-empty. NDI-matlab'sdidsqlite.mis explicit about why "prefer context.uid whenever present" is the wrong rule — DID passes a uid for ordinary files too, and there it is the files-table row, not what thendic://reference names. DID-python passes{"documentId", "filename", "seriesName", "uid", "mode"}.Separately,
download_file_from_cloudcurrentlyreturns silently for a non-ndic://location, and_cloud_file_handlerswallowsImportErrorthe same way. DID reads both as "the handler produced no file". That is the silent-miss shape, arriving by a different route than MATLAB'sUnsupportedFileLocationType.B. Reconstruct
ingest_locationson download. A counterpart toreconstructSeriesIngestLocations.m: for each series withn_present > 0and noingest_locations, find the manifest's uid from thefile_infoentry whose name is the series name, read the downloaded manifest withdid.file.read_series_manifest, and build one entry per present slot —{index, uid, location: ndic://<dsid>/<uid>, location_type: "ndicloud", ingest: 0, delete_original: 0}.ingest: 0matters: the point is to satisfy the guard, not to trigger 28,000 downloads at add time. A series whose manifest cannot be read is left alone so the guard fires — that is the right signal when the download was incomplete.C. Keep the manifest's
ndic://reference beside the local copy.ndi.document.add_filealready detectsndic://→ndicloud(src/ndi/document.py:257) and DID's_is_remote_locationis a"://"test, so anndicloudlocation is classified remote and_manifest_locations_for_handleroffers it first. The primitive is in place; only manifests need the second location.D — the one not in the issue's list: there is no Python download path for this to hang on.
updateFileInfoForLocalFileshas no callers. Not insrc/, not intests/—grep -rn "updateFileInfoForLocalFiles" --include=*.py .finds only its own definition and docstring. Andsync/operations.py:downloadNdiDocumentsfetches document JSON and stops; it never downloads files and never rewritesfile_infoto local paths. MATLAB's does both, then threadscloudDatasetIdintoupdateFileInfoForLocalFilesfor exactly the reconstruction in B.So the
SyncFiles=truehalf of the download path is missing, not just its series support. Step 3 has nowhere to attach until that exists. Worth confirming with a maintainer whether that is known and deliberate (deferred), or a gap of its own — it changes the size of this issue considerably.On the shape of
updateFileInfoForLocalFilesThe issue is right that a transliteration will not work, and the reason compounds. MATLAB takes and returns a
did.document, callsreset_file_info(), and rebuilds throughadd_file— which is how it getsadd_file'sndic://detection and its per-location uid minting for free, and why carryingseries_infoacross the reset was a fix worth making. Python takes a properties dict and editslocations[i]["location"]in place. From a bare dict it cannot calladd_file, so C would mean hand-rolling a second location record, minting a uid by hand, and duplicating the scheme detection thatndi.document.add_filealready does correctly.My suggestion: give the Python function a document-shaped signature —
(doc, file_directory, cloud_dataset_id="") -> doc— rather than growing the dict-editing version. It is the same operation on the same object MATLAB uses, it lets B and C go throughdid/ndi.documentinstead of around them, and since the function has no callers today the change costs nothing. Keeping a thin dict-based wrapper for anything that later wants one is easy; the reverse is not.Proposed order of work
add_file_series, the guard, and_open_series_member, and none of the three has ever run in a test._cloud_file_handleranddownload_file_from_cloud, uid fromcontext["uid"]whencontext["seriesName"]is set. Self-contained, testable with a fake DID caller, and it is the piece the whole feature is pointless without. One PR.updateFileInfoForLocalFilesto take and return a document, withcloud_dataset_id. One PR.reconstructSeriesIngestLocations+ the manifest's second location (B and C). They are the same round trip and want reading together. One PR.extract_doc_files— the member walk fromextract_docs_files.m, keeping each member's ORIGINAL uid, since the copied manifest names members by uid. Independent of the cloud path; can go any time after 0.The test that matters most, and the one the issue is really asking for: a downloaded series whose member cannot be resolved must fail a test, not a user. Concretely — build a document with a populated series, run it through strip/store/read-back, reconstruct, add, then open a member through a fake handler and assert the handler was asked for the member's uid and not the manifest's. That single test covers A, B and C at once and is the one I would write first.
What I am not sure about
api/files.pycan express the same batch call, and I did not look at the cloud API surface for it.contextare DID-python's today; I read them fromsqlitedb._open_series_member. If DID-matlab#186's contract is still moving, NDI's handler should read defensively (context.get(...)) rather than assume the five keys._reject_series_without_ingest_locationsruns only for a document new to the database (inside theelseof thedocsrow lookup). I have not worked out whether a re-add of an existing doc_id can reach the member-ingest loop with an empty record and quietly do nothing.Coordination
I have edited nothing.
claude/burn-down-cloud-drifthas not merged intomain(mainis at767ab85), and every file steps 1–4 touch is in that branch's set, so there is nothing to write here yet regardless.tests/bridge_drift_allowlist.yamluntouched; theupdateFileInfoForLocalFilesandextract_doc_filesdecision_lognotes still say NOT PORTED, which is still true, and should be rewritten by whoever does steps 3–5 rather than now.Generated by Claude Code
Burn the +cloud cluster off the drift allowlist: 18 down to 1
claude commentedon Sep 8, 2026
Done. All five PRs are merged;
mainis at2b8cf12.What shipped
ingest_locations, keep the manifest's cloud referenceAgainst this issue's five scope items:
f292b16/ DID-python#65 landed the day before this issue was written, which is presumably why the issue did not know. So NDI models no part offiles.series_info:series_names,series_count,strip_series_ingest_locationsand the manifest codec are all reached throughdid.documentanddid.file. What is NDI's is thendic://round trip, which DID deliberately knows nothing about ("DID composes no URL and learns no scheme").files.series_infothrough the cloud round trip — Make a downloaded file series readable: rebuild ingest_locations, keep the manifest cloud reference #259.ingest_locationson download,ndic://from the dataset id — Make a downloaded file series readable: rebuild ingest_locations, keep the manifest cloud reference #259,reconstructSeriesIngestLocations.MembersNotLocatableguard — present in DID-python assqlitedb._reject_series_without_ingest_locations, a hardValueErrorindo_add_docbefore any write. The suspicion in this issue was right: without Make a downloaded file series readable: rebuild ingest_locations, keep the manifest cloud reference #259 the first document with a populated series is refused outright, not mis-resolved.Three things this issue did not know about
DID-python's series API was implemented but untested. Coverage on
mainat the time:add_file_series1/54 statements,_open_series_member1/53, the guard 2/5,series_has1/10 — onlydid.file's manifest codec was genuinely covered. No test anywhere constructed a document with a populated series. Filed and worked one layer down rather than around: nine PRs merged in DID-python, four of tests and three of real defects the tests exposed — VH-Lab/DID-python#77 (ingest-path divergences), #78 (the #191 guard poisoning the file cache), #79 (missing file-declaration guards), plus #81 (slot bounds). Those functions are 49/54, 50/54, 5/5, 10/10 now.Python's failure mode differed from MATLAB's.
_cloud_file_handlerdeclared two positional parameters, and DID chooses between the two- and three-argument forms by counting them — silently. So NDI never saw the context, and a member fetch would have downloaded the manifest under the member's uid. DID's #191 guard catches that when it can compare sizes and cannot when the manifest's size is unreadable, in which case every later read of that member returns the manifest for good.Two prerequisites that were not series bugs at all.
downloadFilesForDocumentread a single top-leveldocument["file_uid"], which ordinary documents do not have — so an ordinary document downloaded nothing (#257). AnddownloadDatasetadded documents before downloading files, intotarget/.ndi/files, which is DID's ownFileDir— bypassing ingestion entirely, which is why it looked fine and whyupdateFileInfoForLocalFileshad never had a caller (#258). Neither is visible from the series side, and neither could be worked around from it: the guard fires atadd_docs, so reconstruction needs the manifest on disk before the add.Where I changed my mind
I said here and on #258 that
updateFileInfoForLocalFilesshould be reshaped to take a document, as MATLAB's does. It was not, and I think that was the wrong plan: the whole download pipeline mutates properties dicts in place —updateFileInfoForRemoteFilesdoes too — andndi_document(props)deep-copies, so a document-shaped rewrite would have silently stopped mutating the objects the pipeline holds. It takescloud_dataset_idas a third argument instead, matching MATLAB's arity. The cost is that the manifest's second location record is built directly rather than throughndi.document.add_file; a test pins the two shapes together.One deliberate deviation from MATLAB, in #258: every location gets its
ndic://reference before the local rewrite overwrites what actually arrived, so a file that failed to download keeps a reference that still resolves on demand rather than being dropped from the document. A short download degrades to a slower read instead of a lost file.Bookkeeping
Both
decision_lognotes that said NOT PORTED —updateFileInfoForLocalFilesandextract_doc_files— were rewritten in the PRs that made them untrue.tests/bridge_drift_allowlist.yamlwas never touched:extract_doc_fileshad already been delisted by an earlier burn-down and its hash was already current, so nomatlab_last_sync_hashmoved in any of the five.One behaviour change worth remembering: #260 moved
extract_doc_files' output to MATLAB's layout (target_path/<uid>, flat, no per-documentdocument.json). Nothing in this repo called the old one, but a downstream script could have.What is not covered
porting_deferred(batchSignedUrlLookup, and the signed-URL-set family under issue Bridge completeness red again: 6 signed-URL-set cloud APIs have no entry #206). This is the obvious next piece and probably wants its own issue.Closing; the five listed items are all merged.
Generated by Claude Code