letify 2.8.1__tar.gz → 2.8.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {letify-2.8.1 → letify-2.8.3}/PKG-INFO +1 -1
- {letify-2.8.1 → letify-2.8.3}/docs/SPEC.md +20 -3
- {letify-2.8.1 → letify-2.8.3}/letify/__init__.py +1 -1
- {letify-2.8.1 → letify-2.8.3}/letify/protocol/worker.py +102 -1
- {letify-2.8.1 → letify-2.8.3}/letify/providers/colab.py +39 -5
- {letify-2.8.1 → letify-2.8.3}/letify/runtime/bootstrap.py +4 -2
- {letify-2.8.1 → letify-2.8.3}/letify/runtime/session.py +193 -53
- {letify-2.8.1 → letify-2.8.3}/letify/store/volume.py +16 -9
- {letify-2.8.1 → letify-2.8.3}/letify/transport/nat.py +15 -1
- {letify-2.8.1 → letify-2.8.3}/letify/transport/strategies.py +101 -15
- {letify-2.8.1 → letify-2.8.3}/letify-core/Cargo.lock +3 -3
- {letify-2.8.1 → letify-2.8.3}/letify-core/Cargo.toml +1 -1
- {letify-2.8.1 → letify-2.8.3}/letify-ext/package-lock.json +2 -2
- {letify-2.8.1 → letify-2.8.3}/letify-ext/package.json +1 -1
- {letify-2.8.1 → letify-2.8.3}/pyproject.toml +1 -1
- {letify-2.8.1 → letify-2.8.3}/tests/conftest.py +5 -1
- {letify-2.8.1 → letify-2.8.3}/tests/test_drive_store.py +134 -1
- {letify-2.8.1 → letify-2.8.3}/tests/test_providers.py +72 -3
- {letify-2.8.1 → letify-2.8.3}/tests/test_runtime.py +75 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_strategies.py +125 -0
- {letify-2.8.1 → letify-2.8.3}/.github/workflows/ci.yml +0 -0
- {letify-2.8.1 → letify-2.8.3}/.github/workflows/docs.yml +0 -0
- {letify-2.8.1 → letify-2.8.3}/.github/workflows/publish.yml +0 -0
- {letify-2.8.1 → letify-2.8.3}/.gitignore +0 -0
- {letify-2.8.1 → letify-2.8.3}/.researchtree +0 -0
- {letify-2.8.1 → letify-2.8.3}/CLAUDE.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/LICENSE +0 -0
- {letify-2.8.1 → letify-2.8.3}/PROJECT.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/README.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/COMPONENT.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/INTENT.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/NETWORK.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/01-getting-started.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/02-providers.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/05-concurrency.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/06-cost.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/08-releasing.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/README.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/README.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/locales/README_ko.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/docs/mkdocs.yml +0 -0
- {letify-2.8.1 → letify-2.8.3}/examples/README.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/cli.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/config/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/config/inventory.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/config/login.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/config/schema.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/config/secrets.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/config/writer.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/declare/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/declare/cache.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/declare/env.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/declare/function.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/declare/instance.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/declare/reduce.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/declare/replicas.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/errors.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/install.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/launcher.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/paths.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/protocol/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/protocol/codec.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/protocol/driver.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/protocol/handle.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/protocol/wire.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/base.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/colab_files.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/elice.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/kaggle.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/local.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/modal.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/modal_adapter.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/naming.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/shell.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/tunnel.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/providers/usage.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/capability.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/client.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/cuda.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/executor.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/frames.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/guard.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/tensor.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/trace.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/value.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/loader.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/remoting/probe.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/render.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/runtime/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/runtime/broker.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/runtime/channel.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/runtime/lease.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/runtime/pool.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/runtime/telemetry.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/store/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/store/backends/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/store/backends/drive.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/store/backends/filesystem.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/store/backends/google_auth.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/store/backends/layout.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/store/backends/objects.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/store/cas.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/store/handoff.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/store/pathdata.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/store/sendorder.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/stubs.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/tools.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/transport/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/transport/agent.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/transport/announce.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/transport/link.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/transport/pipeline.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/transport/portmap.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/transport/probe.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/transport/quic.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/transport/rendezvous.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/transport/setup.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify/transport/sshopts.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/.gitignore +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/build.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/.vscodeignore +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/LICENSE +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/README.md +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/media/icon.svg +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/src/cli.ts +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/src/extension.ts +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/src/history.ts +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/src/model.ts +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/src/poll.ts +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/src/render.ts +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/test/model.test.ts +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify-ext/tsconfig.json +0 -0
- {letify-2.8.1 → letify-2.8.3}/letify_providers.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/scripts/sync-main.sh +0 -0
- {letify-2.8.1 → letify-2.8.3}/scripts/version.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/fake_modal_adapter.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_cli.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_cli_output.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_colab_keepalive.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_config.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_core.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_data.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_data_streaming.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_declare.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_device.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_distributed.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_drive_handoff.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_editor_sessions.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_elice.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_frames.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_install.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_kaggle.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_kaggle_link.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_link_log.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_notebook.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_parallel_placement.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_paths.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_portmap.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_protocol.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_quic.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_remoting.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_render.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_ship.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_store.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_stubs.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_transport.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_tunnel_setup.py +0 -0
- {letify-2.8.1 → letify-2.8.3}/tests/test_version.py +0 -0
|
@@ -1162,7 +1162,7 @@ The archive is `<workspace root>/env-archives/<env key>-<platform>.tar`, uncompr
|
|
|
1162
1162
|
|
|
1163
1163
|
The client's inbound link is the weak one, so the store path is slower than the build in both directions. Compression costs more than it saves on the volume, so the archive is not compressed.
|
|
1164
1164
|
|
|
1165
|
-
After a successful sync and interpreter check, the runtime says `caching the environment on the workspace root, <size>`, runs tar from the project directory's parent into a temporary name beside the destination, and renames it into place, so a concurrent session never reads a half written archive. Nothing is sent to the client, and no measured rate gates the pack, because the transfer stays inside the provider.
|
|
1165
|
+
After a successful sync and interpreter check, the runtime says `caching the environment on the workspace root, <size>`, runs tar from the project directory's parent into a temporary name beside the destination, and renames it into place, so a concurrent session never reads a half written archive. The temporary name carries 16 random hex digits, `<archive>.<hex>.partial`, not the process id, because every Modal sandbox runs its worker under the same small pid, so two sessions packing the same environment at once wrote one file and one of them failed with tar exit status 2. A pack that fails for any reason removes its temporary file, says `letify: <runtime>: not caching the environment on the workspace root (<reason>)`, and the session goes on: the environment is already built and checked, so a failed cache never fails the session or its call. Nothing is sent to the client, and no measured rate gates the pack, because the transfer stays inside the provider.
|
|
1166
1166
|
|
|
1167
1167
|
At the start of `install_env`, when that archive exists on the runtime, the runtime says `restoring the environment from the workspace root`, extracts it into the project directory's parent, and validates the interpreter with the same check an archive restore uses. The restore refuses a member whose path escapes the destination and allows the absolute symlinks a `.venv` needs. A directory an earlier session left under the same name is removed first. When validation fails, the runtime removes the extracted tree, syncs, and replaces the archive. `Runtime.env_source` is `"workspace"` after this restore. This path is taken before and instead of the store path, and such a provider never uses the client store or the bucket store for its environment.
|
|
1168
1168
|
|
|
@@ -1275,7 +1275,9 @@ Letify keeps its files in a folder named `letify` at the root of that Drive, one
|
|
|
1275
1275
|
|
|
1276
1276
|
An ephemeral provider uses the Drive store for its environment archive when the machine declares one and the account sets no `bucket`, which keeps precedence. A provider whose environment survives the session on its own disk or volume does not use it. An archive on Drive is an uncompressed tar, because compression was most of the cost: on a Kaggle runtime, packing a 5.5 GB environment as tar.gz and uploading it took about 12 minutes, against an upload of roughly 77 s for the uncompressed tree at the measured rate.
|
|
1277
1277
|
|
|
1278
|
-
Whether an archive is worth it is measured, not assumed, because a provider with a fast package index can build faster than it restores. On a Kaggle runtime the same 5.5 GB environment built in 35.4 s and restored from Drive in about 90 s. So after a sync letify packs only when the build took
|
|
1278
|
+
Whether an archive is worth it is measured, not assumed, because a provider with a fast package index can build faster than it restores. On a Kaggle runtime the same 5.5 GB environment built in 35.4 s and restored from Drive in about 90 s. So after a sync letify packs only when the build took at least twice as long as the archive would take to restore, estimated as the project directory's size over 176 MiB/s, the slower Drive download measured, plus the same size over 170 MB/s, the extraction rate measured on a sandbox disk. The record beside the archive keeps the build seconds and, once a session has restored from it, the restore seconds that session measured; a later session restores only while the measured restore is shorter than the measured build, and syncs otherwise, saying `letify: <runtime>: syncing rather than restoring from Drive, because a restore took <s> s against a <s> s build`. The factor of two is there because packing has a cost of its own that the comparison leaves out: on a Kaggle runtime a 7.2 GB environment that built in 87.6 s against an estimated 81 s restore was packed, which would save about 6 s a session at the price of the pack.
|
|
1279
|
+
|
|
1280
|
+
The pack to Drive runs on the worker beside the session's calls, not before them. After the interpreter check the session starts the pack as a background job and returns, so the first call starts at once; the worker answers calls on its main thread while a separate thread writes the tar and uploads it, and the archive's ref and build record are written only when the upload completes. The upload session needs the archive's digest and size, so the worker reports them once the tar is written, and a client thread that checks every 2 s creates the resumable session and hands its URL to the worker, which then uploads; the client records and reports the result at the start and end of a call and at session close. A session that ends first leaves no ref, and the next session syncs and may pack again. While it runs the pack says nothing more after `caching the environment, <size> GB`; when it ends it says `letify: <runtime>: cached the environment, <size> GB in <s> s (<rate> MiB/s)`, or the Drive failure line above, on standard error of whichever call or session close observes it first. Closing the session waits up to 60 s for a pack still running and then abandons it, saying `letify: <runtime>: not caching the environment, because the session closed <s> s into the pack`.
|
|
1279
1281
|
|
|
1280
1282
|
Drive's own limits apply. The archive counts against the Google account's storage, which is the person's own plan. Google documents a daily upload limit of 750 GB per user for Google Workspace accounts, independent of the storage plan; an environment archive is a few gigabytes, so letify does not approach it. Standard use of the Drive API costs nothing, and Google documents that exceeding its request quota is planned to be charged to the project's billing account later in 2026; letify's requests are a handful per session against a quota of 325,000 units per user per minute.
|
|
1281
1283
|
|
|
@@ -1910,11 +1912,26 @@ It is opt-in because it needs the user's machine to accept inbound SSH and needs
|
|
|
1910
1912
|
|
|
1911
1913
|
The key is per session. letify generates a new ed25519 key pair for each session and appends the public key to the user's `~/.ssh/authorized_keys` with `restrict,port-forwarding,command="/bin/false"` and the comment `letify-session <alias>`. The private key reaches the remote side through the rendezvous. Closing the link removes the line. Before a new session's key is added, every line carrying the `letify-session` marker for that alias is removed, so a crashed session never leaves a key behind.
|
|
1912
1914
|
|
|
1915
|
+
#### Reverse SSH through a jump host <!-- id: reverse-ssh-jump -->
|
|
1916
|
+
|
|
1917
|
+
> `reverse_ssh` takes an optional `jump`, for a user's machine whose SSH server the internet reaches only through another server.
|
|
1918
|
+
|
|
1919
|
+
```toml
|
|
1920
|
+
[colab_pro_plus]
|
|
1921
|
+
reverse_ssh = { address = "168.188.127.175", port = 30008, user = "work", jump = { address = "jump.example.org", port = 20070, user = "root", key = "~/.ssh/id_jump" } }
|
|
1922
|
+
```
|
|
1923
|
+
|
|
1924
|
+
With `jump`, `address` and `port` are the user's SSH server as the jump host reaches it, and the remote side connects to `<user>@<address>:<port>` through `-o ProxyCommand="ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i <session key> -W %h:%p -p <jump port> <jump user>@<jump address>"` and forwards its port back with `-R` as before. It is a ProxyCommand rather than `-J`, because the options given for the main hop on the command line do not reach a `-J` hop.
|
|
1925
|
+
|
|
1926
|
+
The per-session key authorizes both hops. Besides the user's own `~/.ssh/authorized_keys`, letify appends the session's public key to the jump host's `~/.ssh/authorized_keys` with `restrict,port-forwarding,permitopen="<address>:<port>",command="/bin/false"` and the comment `letify-session <alias>`, so the key opens a connection to the user's SSH server and nothing else. letify reaches the jump host from the user's machine with `ssh -i <jump key> -p <jump port> <jump user>@<jump address>`, `BatchMode=yes`, and edits the file there with a shell command; `key` is optional and defaults to the SSH client's own keys. Closing the link removes that line, and before a new session's key is added every line carrying the `letify-session` marker for that alias is removed from the jump host too, as on the user's machine. The jump key never leaves the user's machine.
|
|
1927
|
+
|
|
1928
|
+
A jump host that letify cannot log in to, or whose `authorized_keys` it cannot edit, fails the strategy with `reverse_ssh: the jump host <user>@<address>:<port> refused letify (<reason>)`, before anything is sent to the remote side.
|
|
1929
|
+
|
|
1913
1930
|
### Colab <!-- id: colab-transport -->
|
|
1914
1931
|
|
|
1915
1932
|
> Colab is a `Shell` whose rendezvous is `colab exec`. It has no forward SSH, and its fallback is `colab exec` with the Colab file API.
|
|
1916
1933
|
|
|
1917
|
-
The Colab CLI runs as `uv tool run --from google-colab-cli colab`, with `jupyter-kernel-client<1` pinned, because release 0.6.0 of the CLI calls an API that jupyter-kernel-client 1.0 removed. `colab new` and `colab stop` manage the session. `Colab.sessions()` reads `colab sessions` and returns the first word of each listing line. The CLI prints a session as `[<name>] <id> | Hardware: <hardware> | Variant: <variant>`, so a first word in square brackets gives the name inside them. A line starting with `[colab]` is a message from the CLI, such as `[colab] No active sessions found on server.`, and names no session, so an account with no session returns an empty list.
|
|
1934
|
+
The Colab CLI runs as `uv tool run --from google-colab-cli colab`, with `jupyter-kernel-client<1` pinned, because release 0.6.0 of the CLI calls an API that jupyter-kernel-client 1.0 removed. `colab new` and `colab stop` manage the session. `Colab.sessions()` reads `colab sessions` and returns the first word of each listing line. The CLI prints a session as `[<name>] <id> | Hardware: <hardware> | Variant: <variant>`, so a first word in square brackets gives the name inside them. A line starting with `[colab]` is a message from the CLI, such as `[colab] No active sessions found on server.`, and names no session, so an account with no session returns an empty list. `colab new` runs through the CLI's own Python with a request timeout of 600 s instead of google-auth's 120 s. google-auth binds `google.auth.transport.requests._DEFAULT_TIMEOUT` into `AuthorizedSession.request`'s signature when the module loads, so setting the module value alone changes nothing: the script sets it to 600 and wraps `AuthorizedSession.request` to default `timeout` to it, then calls the CLI's entry point `colab_cli.cli:main`. That is needed because the assignment request for an A100 can take longer than 120 s to answer and the CLI then fails with `ReadTimeout` while the server goes on assigning the machine. letify's own limit on `colab new` stays 900 s.
|
|
1918
1935
|
|
|
1919
1936
|
**An assignment with no session.** `colab new` can fail after Colab has already assigned a machine, for example with `ReadTimeout` after 120 s, and the machine then bills (14.2 compute units per hour for an A100) with no local session state. `colab sessions` lists it as `[?] <endpoint> | ...`, and `colab stop -s <name>` cannot reach it. `Colab.create_session` therefore lists the account's assignments before `colab new`. When `colab new` raises, for any reason, it lists them again, releases every `?` assignment whose endpoint was not in the first list, and re-raises the original error. An assignment that existed before the attempt is never released. Each release prints one line on standard error naming the endpoint. When the first or the second listing fails, one line on standard error names the endpoint check and the error, and the original error is still the one raised. An assignment is released by endpoint with `Client.unassign` of the Colab CLI, run as `uv tool run --from google-colab-cli python -c <script> <endpoint>` with the account's environment, because the CLI has no command for it. `Colab.orphans()` returns the endpoints of the `?` rows of `colab sessions` and `Colab.stop_orphan(endpoint)` releases one, so `letify stop --orphans` covers Colab as it covers Kaggle.
|
|
1920
1937
|
|
|
@@ -2367,6 +2367,7 @@ def _op_push_blob(request):
|
|
|
2367
2367
|
|
|
2368
2368
|
url = request.pop("url")
|
|
2369
2369
|
headers = request.pop("headers", None) or {}
|
|
2370
|
+
cancel = request.pop("cancel", None)
|
|
2370
2371
|
path = _data_file(os.path.expanduser(request["dir"]), request["digest"])
|
|
2371
2372
|
size = os.path.getsize(path)
|
|
2372
2373
|
piece = int(request.get("piece") or (64 << 20))
|
|
@@ -2374,6 +2375,8 @@ def _op_push_blob(request):
|
|
|
2374
2375
|
with open(path, "rb") as handle:
|
|
2375
2376
|
offset = 0
|
|
2376
2377
|
while offset < size:
|
|
2378
|
+
if cancel is not None and cancel.is_set():
|
|
2379
|
+
raise RuntimeError("the upload was abandoned")
|
|
2377
2380
|
block = handle.read(piece)
|
|
2378
2381
|
end = offset + len(block)
|
|
2379
2382
|
span = "bytes %d-%d/%d" % (offset, end - 1, size)
|
|
@@ -2395,6 +2398,98 @@ def _op_push_blob(request):
|
|
|
2395
2398
|
return {"ok": True, "value": {"id": remote_id, "size": size}}
|
|
2396
2399
|
|
|
2397
2400
|
|
|
2401
|
+
#: The one environment pack running beside the session's calls, or None. Spec "Project
|
|
2402
|
+
#: data / environment archive": the pack is a background job on the worker.
|
|
2403
|
+
_PACK_JOB = {}
|
|
2404
|
+
|
|
2405
|
+
|
|
2406
|
+
def _pack_push_run(job, request):
|
|
2407
|
+
"""Pack a directory, wait for the upload target, upload it, and remove the staging dir.
|
|
2408
|
+
|
|
2409
|
+
Runs on its own thread, so the worker's main thread keeps answering calls. The state
|
|
2410
|
+
goes packing, packed, uploading, then done or failed. The staging directory is gone
|
|
2411
|
+
before done or failed is reported.
|
|
2412
|
+
"""
|
|
2413
|
+
import shutil
|
|
2414
|
+
|
|
2415
|
+
began = time.monotonic()
|
|
2416
|
+
directory = os.path.expanduser(request["dir"])
|
|
2417
|
+
result = None
|
|
2418
|
+
try:
|
|
2419
|
+
packed = _op_pack_blob(
|
|
2420
|
+
{"path": request["path"], "dir": request["dir"], "gzip": False}
|
|
2421
|
+
)["value"]
|
|
2422
|
+
job["digest"], job["size"] = packed["digest"], packed["size"]
|
|
2423
|
+
job["state"] = "packed"
|
|
2424
|
+
job["ready"].wait()
|
|
2425
|
+
target = job["target"]
|
|
2426
|
+
if target is None:
|
|
2427
|
+
raise RuntimeError("the pack was abandoned")
|
|
2428
|
+
job["state"] = "uploading"
|
|
2429
|
+
sent = _op_push_blob(
|
|
2430
|
+
{
|
|
2431
|
+
"dir": packed["dir"],
|
|
2432
|
+
"digest": packed["digest"],
|
|
2433
|
+
"url": target["url"],
|
|
2434
|
+
"headers": target.get("headers") or {},
|
|
2435
|
+
"piece": target.get("piece"),
|
|
2436
|
+
"cancel": job["cancel"],
|
|
2437
|
+
}
|
|
2438
|
+
)["value"]
|
|
2439
|
+
result = {"state": "done", "id": sent["id"], "size": sent["size"]}
|
|
2440
|
+
except BaseException as exc:
|
|
2441
|
+
result = {"state": "failed", "reason": str(exc) or type(exc).__name__}
|
|
2442
|
+
finally:
|
|
2443
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
2444
|
+
result["seconds"] = time.monotonic() - began
|
|
2445
|
+
job.update(result)
|
|
2446
|
+
|
|
2447
|
+
|
|
2448
|
+
def _op_pack_push_start(request):
|
|
2449
|
+
"""Start packing a directory on a thread of its own and return at once."""
|
|
2450
|
+
import threading
|
|
2451
|
+
|
|
2452
|
+
current = _PACK_JOB.get("job")
|
|
2453
|
+
if current is not None and current["state"] in ("packing", "packed", "uploading"):
|
|
2454
|
+
return {"ok": False, "error": "a pack is already running", "traceback": ""}
|
|
2455
|
+
job = {
|
|
2456
|
+
"state": "packing",
|
|
2457
|
+
"target": None,
|
|
2458
|
+
"ready": threading.Event(),
|
|
2459
|
+
"cancel": threading.Event(),
|
|
2460
|
+
}
|
|
2461
|
+
_PACK_JOB["job"] = job
|
|
2462
|
+
threading.Thread(target=_pack_push_run, args=(job, request), daemon=True).start()
|
|
2463
|
+
return {"ok": True, "value": None}
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
def _op_pack_push_target(request):
|
|
2467
|
+
"""Hand the running pack its upload session, or abandon it when ``url`` is None."""
|
|
2468
|
+
job = _PACK_JOB.get("job")
|
|
2469
|
+
if job is None:
|
|
2470
|
+
return {"ok": False, "error": "no pack is running", "traceback": ""}
|
|
2471
|
+
if request.get("url") is None:
|
|
2472
|
+
job["cancel"].set()
|
|
2473
|
+
job["target"] = None
|
|
2474
|
+
else:
|
|
2475
|
+
job["target"] = {
|
|
2476
|
+
"url": request["url"],
|
|
2477
|
+
"headers": request.get("headers") or {},
|
|
2478
|
+
"piece": request.get("piece"),
|
|
2479
|
+
}
|
|
2480
|
+
job["ready"].set()
|
|
2481
|
+
return {"ok": True, "value": None}
|
|
2482
|
+
|
|
2483
|
+
|
|
2484
|
+
def _op_pack_push_state(request):
|
|
2485
|
+
"""Report the pack: its state, and the digest, size, file id, seconds or reason it has."""
|
|
2486
|
+
job = _PACK_JOB.get("job")
|
|
2487
|
+
if job is None:
|
|
2488
|
+
return {"ok": True, "value": {"state": "none"}}
|
|
2489
|
+
keys = ("state", "digest", "size", "id", "seconds", "reason")
|
|
2490
|
+
return {"ok": True, "value": {key: job[key] for key in keys if key in job}}
|
|
2491
|
+
|
|
2492
|
+
|
|
2398
2493
|
def _op_place_blob(request):
|
|
2399
2494
|
"""Move a received file blob to its destination path, and unpack it when asked.
|
|
2400
2495
|
|
|
@@ -2539,6 +2634,9 @@ _OPS = {
|
|
|
2539
2634
|
"pack_dir": _op_pack_dir,
|
|
2540
2635
|
"pack_blob": _op_pack_blob,
|
|
2541
2636
|
"push_blob": _op_push_blob,
|
|
2637
|
+
"pack_push_start": _op_pack_push_start,
|
|
2638
|
+
"pack_push_target": _op_pack_push_target,
|
|
2639
|
+
"pack_push_state": _op_pack_push_state,
|
|
2542
2640
|
"place_blob": _op_place_blob,
|
|
2543
2641
|
"exec": _op_exec,
|
|
2544
2642
|
"eval": _op_eval,
|
|
@@ -2553,7 +2651,10 @@ _OPS = {
|
|
|
2553
2651
|
#: Answered by the frame reader at once, so they work while a call runs.
|
|
2554
2652
|
# ``data_failed`` is light because it must not queue behind the call it is about: the call
|
|
2555
2653
|
# is blocked on a blob the sender will never send, so a queued answer would deadlock.
|
|
2556
|
-
_LIGHT = (
|
|
2654
|
+
_LIGHT = (
|
|
2655
|
+
"stat", "lease", "reduce_peers", "drive_grant", "data_failed",
|
|
2656
|
+
"pack_push_state", "pack_push_target",
|
|
2657
|
+
)
|
|
2557
2658
|
|
|
2558
2659
|
#: Answered by several replies, the last one marked. These send their own replies.
|
|
2559
2660
|
_STREAMING = ("data_stream",)
|
|
@@ -131,6 +131,26 @@ UNASSIGN_SCRIPT = (
|
|
|
131
131
|
)
|
|
132
132
|
|
|
133
133
|
|
|
134
|
+
#: Run in the CLI's own Python: ``colab`` with google-auth's request timeout raised to 600 s.
|
|
135
|
+
#: The CLI passes no timeout, so a slow A100 assignment fails at the 120 s default while the
|
|
136
|
+
#: server goes on assigning. google-auth binds the 120 s default into the signature of
|
|
137
|
+
#: ``AuthorizedSession.request`` when it is defined, so setting the module value alone changes
|
|
138
|
+
#: nothing and the method is wrapped as well. Spec "Colab", the Colab CLI paragraph.
|
|
139
|
+
NEW_SCRIPT = (
|
|
140
|
+
"import sys\n"
|
|
141
|
+
"import google.auth.transport.requests as requests\n"
|
|
142
|
+
"requests._DEFAULT_TIMEOUT = 600\n"
|
|
143
|
+
"_request = requests.AuthorizedSession.request\n"
|
|
144
|
+
"def _slow_request(self, *args, **kwargs):\n"
|
|
145
|
+
" kwargs.setdefault('timeout', requests._DEFAULT_TIMEOUT)\n"
|
|
146
|
+
" return _request(self, *args, **kwargs)\n"
|
|
147
|
+
"requests.AuthorizedSession.request = _slow_request\n"
|
|
148
|
+
"from colab_cli.cli import main\n"
|
|
149
|
+
"sys.argv = ['colab', *sys.argv[1:]]\n"
|
|
150
|
+
"sys.exit(main())\n"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
134
154
|
def _parse_assignments(listing: str) -> list[tuple[str, str]]:
|
|
135
155
|
"""The ``(name, endpoint)`` pairs in ``colab sessions`` output, ``?`` for no local state."""
|
|
136
156
|
found = []
|
|
@@ -275,8 +295,21 @@ class Colab(Shell):
|
|
|
275
295
|
|
|
276
296
|
# -- the CLI -------------------------------------------------------------
|
|
277
297
|
|
|
278
|
-
def _cli(
|
|
279
|
-
|
|
298
|
+
def _cli(
|
|
299
|
+
self,
|
|
300
|
+
*args: str,
|
|
301
|
+
timeout: float | None = None,
|
|
302
|
+
stdin: str | None = None,
|
|
303
|
+
script: str | None = None,
|
|
304
|
+
) -> str:
|
|
305
|
+
"""Run the CLI; with ``script``, run that code in the CLI's Python with ``args`` as argv."""
|
|
306
|
+
if script is None:
|
|
307
|
+
command = [*self._colab(), *args]
|
|
308
|
+
else:
|
|
309
|
+
uv = tools.find_uv()
|
|
310
|
+
if uv is None:
|
|
311
|
+
raise ProviderUnavailable(self.kind, tools.missing_uv_message())
|
|
312
|
+
command = [*tools.python_command(tools.COLAB, uv), "-c", script, *args]
|
|
280
313
|
result = subprocess.run(
|
|
281
314
|
command,
|
|
282
315
|
input=stdin,
|
|
@@ -286,9 +319,10 @@ class Colab(Shell):
|
|
|
286
319
|
env=self._env(),
|
|
287
320
|
)
|
|
288
321
|
if result.returncode != 0:
|
|
322
|
+
shown = ["<script>" if part == script else part for part in command]
|
|
289
323
|
raise RuntimeFailure(
|
|
290
|
-
f"`{' '.join(
|
|
291
|
-
command=" ".join(
|
|
324
|
+
f"`{' '.join(shown)}` exited {result.returncode}",
|
|
325
|
+
command=" ".join(shown),
|
|
292
326
|
stderr=(result.stderr or "").strip(),
|
|
293
327
|
)
|
|
294
328
|
return result.stdout
|
|
@@ -482,7 +516,7 @@ class Colab(Shell):
|
|
|
482
516
|
file=sys.stderr,
|
|
483
517
|
)
|
|
484
518
|
try:
|
|
485
|
-
self._cli(*args, timeout=900)
|
|
519
|
+
self._cli(*args, timeout=900, script=NEW_SCRIPT)
|
|
486
520
|
except BaseException:
|
|
487
521
|
# A timed out `colab new` can still leave a server-side assignment behind.
|
|
488
522
|
if before is not None:
|
|
@@ -281,13 +281,15 @@ def workspace_pack_source(root: str, archive: str) -> str:
|
|
|
281
281
|
Spec "Volumes on a persistent runtime": runs ``tar`` from the directory's parent into a
|
|
282
282
|
temporary name and renames it into place, so a concurrent session never reads a half
|
|
283
283
|
written archive. Uncompressed, because gzip costs more than it saves on a local volume.
|
|
284
|
+
The temporary name carries random hex, not the process id, because every Modal sandbox
|
|
285
|
+
runs its worker under the same pid. A failed pack removes its temporary file.
|
|
284
286
|
"""
|
|
285
287
|
return (
|
|
286
|
-
"import os, subprocess\n"
|
|
288
|
+
"import os, secrets, subprocess\n"
|
|
287
289
|
f"_root = os.path.expanduser({root!r}).rstrip('/')\n"
|
|
288
290
|
f"_archive = os.path.expanduser({archive!r})\n"
|
|
289
291
|
"os.makedirs(os.path.dirname(_archive), exist_ok=True)\n"
|
|
290
|
-
"_partial = _archive + '.' +
|
|
292
|
+
"_partial = _archive + '.' + secrets.token_hex(8) + '.partial'\n"
|
|
291
293
|
"try:\n"
|
|
292
294
|
" subprocess.run(['tar', '-cf', _partial, '-C', os.path.dirname(_root),\n"
|
|
293
295
|
" os.path.basename(_root)], check=True, capture_output=True)\n"
|
|
@@ -22,6 +22,7 @@ import datetime
|
|
|
22
22
|
import json
|
|
23
23
|
import posixpath
|
|
24
24
|
import sys
|
|
25
|
+
import threading
|
|
25
26
|
import time
|
|
26
27
|
from collections.abc import Callable, Iterator
|
|
27
28
|
from dataclasses import dataclass, field
|
|
@@ -62,6 +63,16 @@ DRIVE_DOWNLOAD_BPS = 176 * 1024**2
|
|
|
62
63
|
#: 170 MB/s on a sandbox disk. Spec "Materializing into a runtime".
|
|
63
64
|
EXTRACT_BPS = 170_000_000
|
|
64
65
|
|
|
66
|
+
#: How many times the estimated restore a build must have taken before the environment is
|
|
67
|
+
#: packed to Drive. Spec "Materializing into a runtime": packing has a cost of its own.
|
|
68
|
+
DRIVE_PACK_FACTOR = 2
|
|
69
|
+
|
|
70
|
+
#: Seconds a closing session waits for a pack still running before it abandons it.
|
|
71
|
+
PACK_CLOSE_WAIT_S = 60.0
|
|
72
|
+
|
|
73
|
+
#: Seconds between the checks that hand a finished pack its upload session.
|
|
74
|
+
PACK_POLL_S = 2.0
|
|
75
|
+
|
|
65
76
|
|
|
66
77
|
def _against_the_build(size: int, rate: float, build_s: float) -> str:
|
|
67
78
|
"""The three numbers both cache decisions weigh, as they are reported.
|
|
@@ -87,6 +98,15 @@ def _sync_rather_than_restore(archive_bytes: int, rate: float, build_s: float) -
|
|
|
87
98
|
)
|
|
88
99
|
|
|
89
100
|
|
|
101
|
+
@dataclass
|
|
102
|
+
class _DrivePack:
|
|
103
|
+
"""A pack the worker is running for a Drive store, as this process tracks it."""
|
|
104
|
+
|
|
105
|
+
volume: Any
|
|
106
|
+
began: float = field(default_factory=time.monotonic)
|
|
107
|
+
target_sent: bool = False
|
|
108
|
+
|
|
109
|
+
|
|
90
110
|
@dataclass
|
|
91
111
|
class Runtime:
|
|
92
112
|
"""One live session on one provider."""
|
|
@@ -155,6 +175,11 @@ class Runtime:
|
|
|
155
175
|
#: Set once ``shutdown`` has run, so a second discard does nothing.
|
|
156
176
|
_shut_down: bool = field(default=False, repr=False)
|
|
157
177
|
|
|
178
|
+
#: The environment pack running on the worker, or None. Spec "Materializing into a
|
|
179
|
+
#: runtime": the pack to Drive runs beside the session's calls.
|
|
180
|
+
_pack: _DrivePack | None = field(default=None, repr=False)
|
|
181
|
+
_pack_lock: Any = field(default_factory=threading.RLock, repr=False)
|
|
182
|
+
|
|
158
183
|
@property
|
|
159
184
|
def key(self) -> str:
|
|
160
185
|
"""Pool key. Runtimes with equal keys are interchangeable."""
|
|
@@ -215,14 +240,12 @@ class Runtime:
|
|
|
215
240
|
+ (f", {size / 1_000_000_000:.1f} GB" if size else "")
|
|
216
241
|
)
|
|
217
242
|
volume = self._environment_volumes()[0]
|
|
218
|
-
|
|
243
|
+
if volume.store.backend.moves_on_runtime:
|
|
244
|
+
self._start_drive_pack(volume, root)
|
|
245
|
+
else:
|
|
219
246
|
volume.cache_env_from(
|
|
220
247
|
self, self.env, root, platform=self.platform, build_s=self.env_build_s
|
|
221
248
|
)
|
|
222
|
-
except Exception as exc:
|
|
223
|
-
if not volume.store.backend.moves_on_runtime:
|
|
224
|
-
raise
|
|
225
|
-
self._not_using_drive(exc, after="not caching the environment")
|
|
226
249
|
self.keep_blobs_on_disk()
|
|
227
250
|
for volume in self.volumes:
|
|
228
251
|
self.attach(volume)
|
|
@@ -263,6 +286,7 @@ class Runtime:
|
|
|
263
286
|
if self._shut_down:
|
|
264
287
|
return
|
|
265
288
|
self._shut_down = True
|
|
289
|
+
self._close_pack()
|
|
266
290
|
timestamp = datetime.datetime.now(datetime.UTC).isoformat()
|
|
267
291
|
print(
|
|
268
292
|
f"{timestamp} letify: discarding {self.name} provider={self.provider.alias} "
|
|
@@ -371,9 +395,13 @@ class Runtime:
|
|
|
371
395
|
fn, args, kwargs, timeout=timeout, modules=self.env.ship_modules
|
|
372
396
|
)
|
|
373
397
|
args, kwargs = self._externalize(args, kwargs)
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
398
|
+
self._poll_pack()
|
|
399
|
+
try:
|
|
400
|
+
return self._call_with_data(
|
|
401
|
+
fn, args, kwargs, timeout, data_order, data_first_wave, replica
|
|
402
|
+
)
|
|
403
|
+
finally:
|
|
404
|
+
self._poll_pack()
|
|
377
405
|
|
|
378
406
|
def _call_with_data(
|
|
379
407
|
self,
|
|
@@ -695,46 +723,6 @@ class Runtime:
|
|
|
695
723
|
)
|
|
696
724
|
return digest, size
|
|
697
725
|
|
|
698
|
-
def pack_dir_to_store(self, path: str, backend: Any) -> tuple[str, int]:
|
|
699
|
-
"""Pack a directory inside the runtime and have the runtime upload the archive.
|
|
700
|
-
|
|
701
|
-
Spec "Materializing into a runtime": the archive goes from the runtime's blob cache
|
|
702
|
-
to the backend without crossing this process. This process creates the upload
|
|
703
|
-
session, because it holds the credential, and the runtime is sent that session's
|
|
704
|
-
URL alone. Returns ``(digest, size)``. Needs a persistent channel, and raises
|
|
705
|
-
rather than routing the archive through this process.
|
|
706
|
-
"""
|
|
707
|
-
if not self.persistent_channel:
|
|
708
|
-
raise RuntimeFailure("this channel keeps no worker that could upload the archive")
|
|
709
|
-
staging = f"{posixpath.dirname(path.rstrip('/'))}/.letify-pack"
|
|
710
|
-
value = self.request(
|
|
711
|
-
{"op": "pack_blob", "path": path, "dir": staging, "gzip": False}, timeout=3600
|
|
712
|
-
)
|
|
713
|
-
digest, size, directory = value["digest"], int(value["size"]), value["dir"]
|
|
714
|
-
try:
|
|
715
|
-
if not backend.has(digest):
|
|
716
|
-
target = backend.begin_upload(digest, size)
|
|
717
|
-
if target is None:
|
|
718
|
-
raise RuntimeFailure(f"the {backend.name} backend offered no upload")
|
|
719
|
-
sent = self.request(
|
|
720
|
-
{
|
|
721
|
-
"op": "push_blob",
|
|
722
|
-
"dir": directory,
|
|
723
|
-
"digest": digest,
|
|
724
|
-
"url": target["url"],
|
|
725
|
-
"headers": dict(target.get("headers") or {}), # type: ignore[call-overload]
|
|
726
|
-
"piece": target.get("piece"),
|
|
727
|
-
},
|
|
728
|
-
timeout=3600,
|
|
729
|
-
)
|
|
730
|
-
backend.record_upload(digest, str(sent["id"]))
|
|
731
|
-
finally:
|
|
732
|
-
self.exec(
|
|
733
|
-
f"import shutil\nshutil.rmtree({directory!r}, ignore_errors=True)\n",
|
|
734
|
-
timeout=120,
|
|
735
|
-
)
|
|
736
|
-
return digest, size
|
|
737
|
-
|
|
738
726
|
def put_path(
|
|
739
727
|
self,
|
|
740
728
|
local: Path,
|
|
@@ -838,7 +826,16 @@ class Runtime:
|
|
|
838
826
|
"caching the environment on the workspace root"
|
|
839
827
|
+ (f", {size / 1_000_000_000:.1f} GB" if size else "")
|
|
840
828
|
)
|
|
841
|
-
|
|
829
|
+
source = bootstrap.workspace_pack_source(root, self._workspace_archive())
|
|
830
|
+
try:
|
|
831
|
+
self.eval(source, timeout=3600)
|
|
832
|
+
except Exception as error:
|
|
833
|
+
# The environment is built and checked, so a failed cache must not fail the call.
|
|
834
|
+
lines = str(error).strip().splitlines()
|
|
835
|
+
reason = f"{type(error).__name__}: {lines[0]}" if lines else type(error).__name__
|
|
836
|
+
if len(reason) > 200:
|
|
837
|
+
reason = reason[:197] + "..."
|
|
838
|
+
self._say(f"not caching the environment on the workspace root ({reason})")
|
|
842
839
|
|
|
843
840
|
def _environment_volumes(self) -> tuple[Volume, ...]:
|
|
844
841
|
"""Explicit archive volumes, or the automatic cache where the project does not survive.
|
|
@@ -937,24 +934,167 @@ class Runtime:
|
|
|
937
934
|
return False
|
|
938
935
|
|
|
939
936
|
def _worth_packing_to_drive(self, root: str) -> bool:
|
|
940
|
-
"""Whether the build took
|
|
937
|
+
"""Whether the build took at least twice as long as restoring from Drive would.
|
|
941
938
|
|
|
942
939
|
Spec "Materializing into a runtime": the archive goes from the runtime to Drive and
|
|
943
940
|
never over this link, so the gate is the estimated restore, the project directory's
|
|
944
|
-
size over the Drive download rate plus the same size over the extraction rate.
|
|
941
|
+
size over the Drive download rate plus the same size over the extraction rate. The
|
|
942
|
+
build must take ``DRIVE_PACK_FACTOR`` times that, because the pack has a cost of its
|
|
943
|
+
own that the comparison leaves out.
|
|
945
944
|
"""
|
|
946
945
|
size = self._tree_size(root)
|
|
947
946
|
if size is None or self.env_build_s is None:
|
|
948
947
|
return True
|
|
949
948
|
restore_s = size / DRIVE_DOWNLOAD_BPS + size / EXTRACT_BPS
|
|
950
|
-
if self.env_build_s
|
|
949
|
+
if self.env_build_s >= DRIVE_PACK_FACTOR * restore_s:
|
|
951
950
|
return True
|
|
952
951
|
self._say(
|
|
953
952
|
f"not caching the environment, because it built in {self.env_build_s:.0f} s "
|
|
954
|
-
f"and would take an estimated {restore_s:.0f} s to restore from Drive"
|
|
953
|
+
f"and would take an estimated {restore_s:.0f} s to restore from Drive, and a "
|
|
954
|
+
f"cache pays only when the build takes at least {DRIVE_PACK_FACTOR} times as long"
|
|
955
955
|
)
|
|
956
956
|
return False
|
|
957
957
|
|
|
958
|
+
# -- the pack that runs beside the calls ---------------------------------
|
|
959
|
+
|
|
960
|
+
def _start_drive_pack(self, volume: Volume, root: str) -> None:
|
|
961
|
+
"""Have the worker pack the environment and upload it on a thread of its own.
|
|
962
|
+
|
|
963
|
+
Spec "Materializing into a runtime": this returns at once, so the first call does
|
|
964
|
+
not wait for the pack. The ref and the build record are written when a poll sees
|
|
965
|
+
the upload finish. A failure to start leaves nothing cached and says so.
|
|
966
|
+
"""
|
|
967
|
+
staging = f"{posixpath.dirname(root.rstrip('/'))}/.letify-pack"
|
|
968
|
+
try:
|
|
969
|
+
if not self.persistent_channel:
|
|
970
|
+
raise RuntimeFailure("this channel keeps no worker that could upload the archive")
|
|
971
|
+
self.request(
|
|
972
|
+
{"op": "pack_push_start", "path": root, "dir": staging}, timeout=120
|
|
973
|
+
)
|
|
974
|
+
except Exception as exc:
|
|
975
|
+
self._not_using_drive(exc, after="not caching the environment")
|
|
976
|
+
return
|
|
977
|
+
self._pack = _DrivePack(volume)
|
|
978
|
+
threading.Thread(target=self._feed_pack, name="letify-pack", daemon=True).start()
|
|
979
|
+
|
|
980
|
+
def _feed_pack(self) -> None:
|
|
981
|
+
"""Hand the pack its upload session soon after the archive is written.
|
|
982
|
+
|
|
983
|
+
A call can run for hours, and the upload cannot start until this process has made
|
|
984
|
+
the session, so this does only that. Reporting stays with the calls and the close.
|
|
985
|
+
"""
|
|
986
|
+
while True:
|
|
987
|
+
time.sleep(PACK_POLL_S)
|
|
988
|
+
job = self._pack
|
|
989
|
+
if job is None or job.target_sent or self._shut_down:
|
|
990
|
+
return
|
|
991
|
+
self._poll_pack(report=False)
|
|
992
|
+
|
|
993
|
+
def _poll_pack(self, *, report: bool = True) -> bool:
|
|
994
|
+
"""Ask the worker how the pack is doing and act on the answer.
|
|
995
|
+
|
|
996
|
+
A packed archive is given its upload session. A finished upload is recorded, and
|
|
997
|
+
reported when ``report`` is set. False when the worker could not be asked.
|
|
998
|
+
"""
|
|
999
|
+
with self._pack_lock:
|
|
1000
|
+
job = self._pack
|
|
1001
|
+
if job is None or self.channel is None:
|
|
1002
|
+
return True
|
|
1003
|
+
try:
|
|
1004
|
+
state = self.request({"op": "pack_push_state"}, timeout=30)
|
|
1005
|
+
except Exception:
|
|
1006
|
+
return False
|
|
1007
|
+
kind = state.get("state")
|
|
1008
|
+
backend = job.volume.store.backend
|
|
1009
|
+
if kind == "packed" and not job.target_sent:
|
|
1010
|
+
job.target_sent = True
|
|
1011
|
+
try:
|
|
1012
|
+
if backend.has(state["digest"]):
|
|
1013
|
+
# Another session stored this archive first, so there is nothing to send.
|
|
1014
|
+
self._send_target(None)
|
|
1015
|
+
self._pack = None
|
|
1016
|
+
self._record_pack(job, state["digest"], int(state["size"]))
|
|
1017
|
+
return True
|
|
1018
|
+
target = backend.begin_upload(state["digest"], int(state["size"]))
|
|
1019
|
+
if target is None:
|
|
1020
|
+
raise RuntimeFailure(f"the {backend.name} backend offered no upload")
|
|
1021
|
+
self._send_target(target)
|
|
1022
|
+
except Exception as exc:
|
|
1023
|
+
self._pack = None
|
|
1024
|
+
try:
|
|
1025
|
+
self._send_target(None)
|
|
1026
|
+
except Exception:
|
|
1027
|
+
pass
|
|
1028
|
+
self._not_using_drive(exc, after="not caching the environment")
|
|
1029
|
+
elif kind == "done" and report:
|
|
1030
|
+
self._pack = None
|
|
1031
|
+
backend.record_upload(state["digest"], str(state["id"]))
|
|
1032
|
+
self._record_pack(job, state["digest"], int(state["size"]))
|
|
1033
|
+
seconds = float(state["seconds"])
|
|
1034
|
+
size = int(state["size"])
|
|
1035
|
+
self._say(
|
|
1036
|
+
f"cached the environment, {size / 1_000_000_000:.1f} GB in {seconds:.0f} s "
|
|
1037
|
+
f"({size / 1024**2 / max(seconds, 0.001):.1f} MiB/s)"
|
|
1038
|
+
)
|
|
1039
|
+
elif kind == "failed" and report:
|
|
1040
|
+
self._pack = None
|
|
1041
|
+
self._not_using_drive(
|
|
1042
|
+
RuntimeFailure(str(state.get("reason"))), after="not caching the environment"
|
|
1043
|
+
)
|
|
1044
|
+
elif kind == "none":
|
|
1045
|
+
self._pack = None
|
|
1046
|
+
return True
|
|
1047
|
+
|
|
1048
|
+
def _send_target(self, target: dict[str, Any] | None) -> None:
|
|
1049
|
+
"""Give the worker the upload session, or None to abandon the pack."""
|
|
1050
|
+
payload: dict[str, Any] = {"op": "pack_push_target", "url": None}
|
|
1051
|
+
if target is not None:
|
|
1052
|
+
payload.update(
|
|
1053
|
+
url=target["url"],
|
|
1054
|
+
headers=dict(target.get("headers") or {}), # type: ignore[call-overload]
|
|
1055
|
+
piece=target.get("piece"),
|
|
1056
|
+
)
|
|
1057
|
+
self.request(payload, timeout=60)
|
|
1058
|
+
|
|
1059
|
+
def _record_pack(self, job: _DrivePack, digest: str, size: int) -> None:
|
|
1060
|
+
"""Write the ref and the build record, which only a finished upload earns."""
|
|
1061
|
+
job.volume.record_env(
|
|
1062
|
+
self.env, self.platform, digest, size, build_s=self.env_build_s
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
def _close_pack(self) -> None:
|
|
1066
|
+
"""Wait for a running pack, up to ``PACK_CLOSE_WAIT_S``, then abandon it.
|
|
1067
|
+
|
|
1068
|
+
Spec "Materializing into a runtime": an abandoned pack records nothing, so the
|
|
1069
|
+
next session syncs and may pack again.
|
|
1070
|
+
"""
|
|
1071
|
+
job = self._pack
|
|
1072
|
+
if job is None:
|
|
1073
|
+
return
|
|
1074
|
+
deadline = time.monotonic() + PACK_CLOSE_WAIT_S
|
|
1075
|
+
try:
|
|
1076
|
+
while self._pack is not None:
|
|
1077
|
+
if not self._poll_pack():
|
|
1078
|
+
self._pack = None
|
|
1079
|
+
return
|
|
1080
|
+
if self._pack is None:
|
|
1081
|
+
return
|
|
1082
|
+
if time.monotonic() >= deadline:
|
|
1083
|
+
break
|
|
1084
|
+
time.sleep(0.2)
|
|
1085
|
+
with self._pack_lock:
|
|
1086
|
+
self._pack = None
|
|
1087
|
+
try:
|
|
1088
|
+
self._send_target(None)
|
|
1089
|
+
except Exception:
|
|
1090
|
+
pass
|
|
1091
|
+
self._say(
|
|
1092
|
+
"not caching the environment, because the session closed "
|
|
1093
|
+
f"{time.monotonic() - job.began:.0f} s into the pack"
|
|
1094
|
+
)
|
|
1095
|
+
except Exception:
|
|
1096
|
+
self._pack = None
|
|
1097
|
+
|
|
958
1098
|
def _restore_from(self, volume: Volume, where: dict[str, Any]) -> bool:
|
|
959
1099
|
"""Restore the environment from one volume's archive. True when the check passed.
|
|
960
1100
|
|