Skip to content

feat(registry)!: publish a local release with put_release - #648

Merged
karhu merged 5 commits into
mainfrom
tnet-157/2-put-release
Nov 10, 2026
Merged

karhu merged 5 commits into
mainfrom
tnet-157/2-put-release

Conversation

@karhu

@karhu karhu commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

The Stack

This stack covers refactoring the internal logic of how releases are resolved during read, download & build, in particular also in the context of parent releases / parent datasets. On top of that it refactors the public TimeNet APIs and CLI interfaces around these actions, to streamline the behavior across them.

This PR

Splits out the upload part from the current locally-store-then-upload flow of releasing an in-memory dataset to a registry. This allows reusing it for uploads from a version already on disk.

Details

Today the remote and S3 registries can only compile a dataset and upload the result inside store().

  • WritableRegistry.put_release(from_directory) publishes a complete prebuilt local release on the local, remote and S3 registries.
  • Before anything is published, it reads manifest.json from the directory and makes sure that every declared file has its recorded size. It publishes only the declared files.
  • store() becomes put_dataset(), so the two write methods pair up: put_dataset() takes an in-memory dataset, put_release() a release that is already on disk. put_release() only reads from_directory.
  • put_dataset() and put_release() both return the DatasetRef of the stored release.
  • The remote and S3 put_dataset() now compile into staging and call put_release(). The local registry and the writer share one helper, publish_directory() in timenet/_fs.py. It swaps a staged copy into place. If the swap fails, it moves the old copy back.
  • timenet.manifest gets read_manifest() and parse_manifest(). Every registry reads a stored manifest through them. A manifest that cannot be read or does not parse raises TimeFFormatError, and the message names the file or the release.

Design decisions

  • put_release() does not compare with a copy that the destination already holds. The copy function in the next PR decides whether a destination can receive a version.
  • A bad directory fails the same way on every backend, before any upload. A missing or unreadable manifest.json raises TimeFFormatError. A missing file or a wrong size raises TimeNetFileError.

Breaking changes

  • WritableRegistry has a new abstract method, put_release(). A subclass outside this repository must implement it.
  • WritableRegistry.store() is now put_dataset() on every registry, with the same parameters. Rename each call.
  • A stored manifest that does not parse raises TimeFFormatError, not a pydantic ValidationError.

Stack

Changes later in the stack:

Merge message

feat(registry)!: publish a local release with put_release

The local, remote and S3 registries publish releases already on disk
through put_release(), so uploads no longer need an in-memory dataset.

- put_release() reads manifest.json and checks declared file sizes
  before publishing. It leaves the source directory unchanged.
- It does not compare the release with an existing destination copy.
- Remote and S3 put_dataset() stage a build and call put_release().
- Local writes share a directory swap that tries to restore the old
  copy if publishing fails.

BREAKING CHANGE: Registry store() becomes put_dataset() and returns a
DatasetRef instead of a version string. WritableRegistry subclasses
must also implement put_release().

BREAKING CHANGE: Invalid stored manifests raise TimeFFormatError
instead of pydantic.ValidationError.

Comment thread docs/registry.md Resolved
Comment thread docs/registry.md Resolved
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

✅ CI checks passed

Check Status
Quick checks ✅ Passed
Tests (Python 3.13) ✅ Passed
Tests (Python 3.11) ✅ Passed

Updated for 3719fb9 · run #8249

@TimoStoff TimoStoff left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! THere are some nits, so please do look at them before landing :)

Comment thread docs/registry.md Resolved
Comment thread docs/registry.md Resolved

Copy link
Copy Markdown
Contributor

Mostly opportunities to delete more! SO you should be happy

@karhu
karhu force-pushed the tnet-157/2-put-release branch from f5b4644 to 5507f11 Compare November 5, 2026 08:16
@karhu
karhu force-pushed the tnet-157/1-groundwork branch 2 times, most recently from 06721f0 to 4a056ff Compare November 5, 2026 14:05
@karhu
karhu force-pushed the tnet-157/2-put-release branch from 5507f11 to 26387eb Compare November 5, 2026 14:05
@karhu
karhu force-pushed the tnet-157/2-put-release branch from 26387eb to 8f3725f Compare November 5, 2026 20:07
@karhu
karhu force-pushed the tnet-157/1-groundwork branch from 4a056ff to 25a52fd Compare November 5, 2026 20:07
@karhu
karhu force-pushed the tnet-157/2-put-release branch from 8f3725f to a8cf860 Compare November 6, 2026 07:54
@karhu
karhu force-pushed the tnet-157/1-groundwork branch from 25a52fd to 7ca6140 Compare November 6, 2026 07:54
@karhu
karhu removed this pull request from stack #650 November 6, 2026 08:09
@karhu
karhu added this pull request to stack #699 November 6, 2026 08:10
@karhu
karhu force-pushed the tnet-157/1-groundwork branch from 7ca6140 to 3527f95 Compare November 6, 2026 13:54
@karhu
karhu force-pushed the tnet-157/2-put-release branch from a8cf860 to 1e73239 Compare November 6, 2026 13:54

@RealLast RealLast left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LMGTM, thanks! A few nits I'd like to see addressed please on ownership of functionality

Comment thread packages/timenet/src/timenet/_fs.py Resolved
@karhu karhu changed the title feat(registry)!: publish a complete local release with put_release feat(registry)!: publish a local release with put_release Nov 9, 2026

@RealLast RealLast left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, accepting to unblock : ) We can clean up later

@karhu
karhu force-pushed the tnet-157/1-groundwork branch from 3527f95 to a54a43b Compare November 10, 2026 14:39
@karhu
karhu force-pushed the tnet-157/2-put-release branch from 1e73239 to 22f28b9 Compare November 10, 2026 14:39

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 packages/timenet/src/timenet/registry/writable.py — A caller of put_release can publish a release whose payload sources the destination lacks; it then fails on every open. put_dataset never publishes such a release. _read_release only checks manifest.files.all_files() sizes and never looks at manifest.payload_sources. The local, remote and S3 put_release then publish without the per-source check TimeFWriter(registry=self) runs. Fix: before publishing, have _read_release (or each put_release) check every entry of manifest.payload_sources against self, the way the writer does.

    Why this was flagged

    Trigger: a release directory built in another registry whose manifest records payload_sources (reused parent files), passed to LocalRegistry.put_release (local.py:503), S3Registry.put_release (s3.py:790) or RemoteRegistry.put_release (remote/registry.py:605). WritableRegistry._read_release (writable.py:967-971) only runs check_file_sizes on manifest.files.all_files(), so a missing parent does not stop the publish. The release is copied or uploaded and committed. Every later open then fails: locally open_local_release raises TimeNetMissingParentError or TimeNetDatasetNotFoundError (dependencies.py:101-103). Before this change the only write path was store(), where TimeFWriter(..., registry=self) (writer.py:146-153) checks each payload source in the target registry before anything is published. The PR's design note only rules out comparing against an existing copy of the same release, not checking parents.

    Verification: Severity: nit. WritableRegistry._read_release (writable.py) only runs read_manifest(directory / MANIFEST_FILE) and check_file_sizes(version, manifest.files.all_files(), owner=...). Nothing in _read_release reads manifest.payload_sources. Later opens go through attach_payload_sources (dependencies.py:134-138), which raises TimeNetMissingParentError for a missing parent. The only caller of put_release in the repo is LazyReadRegistry.put_dataset (lazy_read.py:72).

  • 🟡 packages/timenet/src/timenet/registry/local.py — A caller that replaces a release with put_release can keep getting the old manifest from the same LocalRegistry, so the new files are read through stale records. put_release copies manifest.json with shutil.copy2, which keeps the source file's mtime. The manifest cache at local.py:103-105 only checks st_mtime, so a new manifest with the same mtime counts as a cache hit. Fix: drop the _manifest_cache entry for the reference after publish_directory, or copy the manifest without its metadata (shutil.copy), so every publish shows the new manifest.

    Why this was flagged

    The trigger is a LocalRegistry instance that read a release (which cached (mtime, manifest) at local.py:113) and then gets put_release(from_directory) for the same dataset@ version. The new manifest's source has the same mtime as the old one. This is common when releases come from archives with a fixed mtime (reproducible tarballs with SOURCE_DATE_EPOCH, rsync -a, cp -p). The copy loop in put_release at local.py:211-214 uses shutil.copy2, which keeps the source mtime. It never invalidates self._manifest_cache. The next get_manifest/open_version compares only path.stat().st_mtime (local.py:103-105) and returns the old cached manifest for the new release directory. Its file records (sizes, checksums, paths) then no longer match the files on disk. On the base branch the only local write path was the writer, which writes a new manifest with a current mtime, so the mtime check always caught a replacement.

    Verification: get_manifest (local.py:103-106) returns the cached manifest when cached[0] == mtime. The new put_release copies with shutil.copy2 at local.py:211-214, which keeps the source mtime, and never drops _manifest_cache[(dataset_id, version)]. On the base, every local write went through TimeFWriter and produced a fresh mtime.

Base automatically changed from tnet-157/1-groundwork to main November 10, 2026 15:50
WritableRegistry.put_release(directory, manifest) publishes a complete local release on the local,
remote and S3 registries. Before anything is published, it makes sure that manifest.json is there
and equals the given manifest, and that every declared file has its recorded size. It publishes
only the declared files. It does not compare with a copy that the registry already holds.

The remote and S3 store() now compile into staging and call put_release(). The local registry and
the writer share one helper that swaps a staged copy into place. If the swap fails, the helper
moves the old copy back.

WritableRegistry has a new abstract method, so a subclass outside this repository must implement
it.

Part of TNET-157.
…ut of put_release()

WritableRegistry.store() is now put_dataset(), with the same parameters, on the local, remote and
S3 registries and in the test fakes. It turns a TimeFDataset in memory into TimeF files and
publishes them. The new name pairs it with put_release(), which publishes a release that is
already on disk. Every call site, test name and docstring uses the new name.

The first parameter of put_release() is now from_directory. The name and the docstring say that
the registry only reads that directory and does not change it.

TimeFWriter.close() no longer has a comment about what its callers pass for replace.

BREAKING CHANGE: WritableRegistry.store() is now put_dataset(), and the directory parameter of
put_release() is now from_directory. There is no alias. Code that calls store(), or passes
directory= to put_release(), must use the new names. A subclass of WritableRegistry must rename
both.

Part of TNET-157.
…ctory

put_release() reads manifest.json from the release directory. A missing or
unreadable manifest raises TimeFFormatError.
put_dataset() and put_release() return the DatasetRef of the stored release.
publish_pipeline() passes pinned_by to put_dataset() directly.
publish_directory() moves to the private module timenet._fs, with its own
tests.
The docstrings of the registry write methods are shorter.

BREAKING CHANGE: put_release(from_directory) takes no manifest; put_dataset() and put_release() return a DatasetRef.

Part of TNET-157.
Shorter docstrings for the code that this change adds or changes.

Part of TNET-157.
A cut or invalid manifest.json made reads, copies and lists of a local
registry fail with a raw pydantic ValidationError and a traceback. The
new read_manifest() and parse_manifest() in timenet.manifest raise
TimeFFormatError instead. The message names the file, or the release
and the registry, and the first error. Every place that parses a stored
manifest uses them.

The remote and the S3 registry had two identical copies of
put_dataset(). One copy in LazyReadRegistry now serves both.

Part of TNET-157.
@karhu
karhu force-pushed the tnet-157/2-put-release branch from 22f28b9 to 3719fb9 Compare November 10, 2026 15:50
@karhu
karhu removed this pull request from stack #699 November 10, 2026 15:58
@karhu
karhu merged commit 6cb9375 into main Nov 10, 2026
5 checks passed
@karhu
karhu deleted the tnet-157/2-put-release branch November 10, 2026 16:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants