letify 2.8.0__tar.gz → 2.8.2__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.0 → letify-2.8.2}/PKG-INFO +1 -1
- {letify-2.8.0 → letify-2.8.2}/docs/SPEC.md +22 -1
- {letify-2.8.0 → letify-2.8.2}/letify/__init__.py +1 -1
- {letify-2.8.0 → letify-2.8.2}/letify/protocol/worker.py +107 -1
- {letify-2.8.0 → letify-2.8.2}/letify/providers/colab.py +100 -1
- {letify-2.8.0 → letify-2.8.2}/letify/runtime/session.py +183 -52
- {letify-2.8.0 → letify-2.8.2}/letify/store/handoff.py +14 -3
- {letify-2.8.0 → letify-2.8.2}/letify/store/volume.py +16 -9
- {letify-2.8.0 → letify-2.8.2}/letify/tools.py +10 -1
- {letify-2.8.0 → letify-2.8.2}/letify/transport/nat.py +15 -1
- {letify-2.8.0 → letify-2.8.2}/letify/transport/strategies.py +101 -15
- {letify-2.8.0 → letify-2.8.2}/letify-core/Cargo.lock +3 -3
- {letify-2.8.0 → letify-2.8.2}/letify-core/Cargo.toml +1 -1
- {letify-2.8.0 → letify-2.8.2}/letify-ext/package-lock.json +2 -2
- {letify-2.8.0 → letify-2.8.2}/letify-ext/package.json +1 -1
- {letify-2.8.0 → letify-2.8.2}/pyproject.toml +1 -1
- {letify-2.8.0 → letify-2.8.2}/tests/conftest.py +42 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_drive_handoff.py +74 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_drive_store.py +108 -1
- {letify-2.8.0 → letify-2.8.2}/tests/test_providers.py +71 -1
- {letify-2.8.0 → letify-2.8.2}/tests/test_strategies.py +125 -0
- {letify-2.8.0 → letify-2.8.2}/.github/workflows/ci.yml +0 -0
- {letify-2.8.0 → letify-2.8.2}/.github/workflows/docs.yml +0 -0
- {letify-2.8.0 → letify-2.8.2}/.github/workflows/publish.yml +0 -0
- {letify-2.8.0 → letify-2.8.2}/.gitignore +0 -0
- {letify-2.8.0 → letify-2.8.2}/.researchtree +0 -0
- {letify-2.8.0 → letify-2.8.2}/CLAUDE.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/LICENSE +0 -0
- {letify-2.8.0 → letify-2.8.2}/PROJECT.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/README.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/COMPONENT.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/INTENT.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/NETWORK.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/01-getting-started.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/02-providers.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/05-concurrency.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/06-cost.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/08-releasing.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/README.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.8.0 → letify-2.8.2}/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.0 → letify-2.8.2}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.8.0 → letify-2.8.2}/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.0 → letify-2.8.2}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/guide/ko/README.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/locales/README_ko.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/docs/mkdocs.yml +0 -0
- {letify-2.8.0 → letify-2.8.2}/examples/README.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.8.0 → letify-2.8.2}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/cli.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/config/__init__.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/config/inventory.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/config/login.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/config/schema.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/config/secrets.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/config/writer.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/declare/__init__.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/declare/cache.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/declare/env.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/declare/function.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/declare/instance.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/declare/reduce.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/declare/replicas.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/errors.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/install.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/launcher.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/paths.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/protocol/__init__.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/protocol/codec.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/protocol/driver.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/protocol/handle.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/protocol/wire.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/__init__.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/base.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/colab_files.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/elice.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/kaggle.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/local.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/modal.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/modal_adapter.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/naming.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/shell.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/tunnel.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/providers/usage.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/__init__.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/capability.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/device/__init__.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/device/client.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/device/cuda.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/device/executor.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/device/frames.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/device/guard.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/device/tensor.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/device/trace.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/device/value.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/loader.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/remoting/probe.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/render.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/runtime/__init__.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/runtime/bootstrap.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/runtime/broker.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/runtime/channel.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/runtime/lease.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/runtime/pool.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/runtime/telemetry.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/store/__init__.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/store/backends/__init__.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/store/backends/drive.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/store/backends/filesystem.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/store/backends/google_auth.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/store/backends/layout.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/store/backends/objects.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/store/cas.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/store/pathdata.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/store/sendorder.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/stubs.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/transport/__init__.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/transport/agent.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/transport/announce.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/transport/link.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/transport/pipeline.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/transport/portmap.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/transport/probe.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/transport/quic.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/transport/rendezvous.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/transport/setup.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify/transport/sshopts.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/.gitignore +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/build.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/.vscodeignore +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/LICENSE +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/README.md +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/media/icon.svg +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/src/cli.ts +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/src/extension.ts +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/src/history.ts +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/src/model.ts +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/src/poll.ts +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/src/render.ts +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/test/model.test.ts +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify-ext/tsconfig.json +0 -0
- {letify-2.8.0 → letify-2.8.2}/letify_providers.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/scripts/sync-main.sh +0 -0
- {letify-2.8.0 → letify-2.8.2}/scripts/version.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/fake_modal_adapter.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_cli.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_cli_output.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_colab_keepalive.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_config.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_core.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_data.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_data_streaming.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_declare.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_device.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_distributed.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_editor_sessions.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_elice.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_frames.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_install.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_kaggle.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_kaggle_link.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_link_log.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_notebook.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_parallel_placement.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_paths.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_portmap.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_protocol.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_quic.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_remoting.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_render.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_runtime.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_ship.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_store.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_stubs.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_transport.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_tunnel_setup.py +0 -0
- {letify-2.8.0 → letify-2.8.2}/tests/test_version.py +0 -0
|
@@ -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
|
|
|
@@ -1565,6 +1567,8 @@ Both run in two places with the same result:
|
|
|
1565
1567
|
|
|
1566
1568
|
A body run on a one-shot channel, which has no stream back to the local process, cannot ask for a grant, so `stash` and `unstash` there raise `UnsupportedMode` naming the provider rather than reading the runtime's own configuration.
|
|
1567
1569
|
|
|
1570
|
+
The worker sets the environment variable `LETIFY_WORKER_PID` to its process id, so a process the body starts, such as one per GPU, inherits it without the grant hook. `stash` and `unstash` called there raise `UnsupportedMode` naming the handoff, saying the call must be made from the process letify runs the body in and not from a child process it started, and neither reads the configuration nor touches Drive.
|
|
1571
|
+
|
|
1568
1572
|
When the machine declares no Drive store, both raise `ConfigError` naming `drive_login` and `drive_project`. letify never falls back to another store or to the local disk. A Drive request that fails raises `RuntimeFailure` naming the Drive status and the name, and the function body decides whether to carry on.
|
|
1569
1573
|
|
|
1570
1574
|
## Environment
|
|
@@ -1908,12 +1912,29 @@ It is opt-in because it needs the user's machine to accept inbound SSH and needs
|
|
|
1908
1912
|
|
|
1909
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.
|
|
1910
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
|
+
|
|
1911
1930
|
### Colab <!-- id: colab-transport -->
|
|
1912
1931
|
|
|
1913
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.
|
|
1914
1933
|
|
|
1915
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.
|
|
1916
1935
|
|
|
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.
|
|
1937
|
+
|
|
1917
1938
|
**Runtime tools.** A runtime whose rendezvous runs a Python program on the machine, Colab and Kaggle both, installs tailcat lazily when the tailcat strategy requests its endpoint, within that strategy's race thread. The install belongs to the rendezvous and not to one provider, so a provider that leaves it out is a defect rather than a choice: without it the tailcat request carries no path, the remote side runs a bare `tailcat`, and the strategy fails with `FileNotFoundError` on a runtime image that ships no such binary. Target construction performs no remote installation. TCP punch rendezvous never waits for tailcat preparation, even if installation fails or takes its full 180 second timeout. A locally available `tailcat` or configured `tailcat_binary` requires the Linux release pinned by `letify.install.TAILCAT_VERSION`, `TAILCAT_RELEASE` and `TAILCAT_SHA256`. The runtime selects its own architecture, verifies the archive SHA-256, extracts only a regular `tailcat` member into `~/.letify/tools/tailcat/<version>/tailcat`, and verifies the cached binary against its recorded SHA-256 on reuse. Only tailcat requests carry that absolute path. Installation errors name the tool and verification failure and fail that strategy. Tailcat remains eligible because throughput is measured per link rather than inferred from another session. The shared account throughput floor still rejects slow links.
|
|
1918
1939
|
|
|
1919
1940
|
**Rendezvous scheduling.** Provider `colab exec` commands, including TCP punch rendezvous and tailcat preparation, do not acquire the wake lock. The lock protects concurrent wakes sharing CLI session state; rendezvous is a separate operation whose agreed dial time cannot wait behind a wake's 120 second timeout. Wakes acquire the account lock without waiting. If another wake holds it, the daemon records `wake_skipped` with reason `account_busy`, logs the skip and waits 60 seconds before trying again. Successful acquisition still holds the lock through the wake command so two wakes cannot collide. An already running wake cannot hold a lock needed by a connection.
|
|
@@ -1346,6 +1346,11 @@ _DRIVE_LOCK = threading.Condition()
|
|
|
1346
1346
|
#: ``stash`` or ``unstash`` takes them.
|
|
1347
1347
|
_DRIVE_ANSWERS = {}
|
|
1348
1348
|
|
|
1349
|
+
# A process the body starts inherits this and lacks ``_drive_ask``, which is how
|
|
1350
|
+
# ``stash`` and ``unstash`` tell it is a child of the body and refuse. Spec "Named handoffs
|
|
1351
|
+
# on the Drive store".
|
|
1352
|
+
os.environ["LETIFY_WORKER_PID"] = str(os.getpid())
|
|
1353
|
+
|
|
1349
1354
|
|
|
1350
1355
|
def _drive_ask(message):
|
|
1351
1356
|
"""Ask the client for a Drive grant. Nothing replies to this request.
|
|
@@ -2362,6 +2367,7 @@ def _op_push_blob(request):
|
|
|
2362
2367
|
|
|
2363
2368
|
url = request.pop("url")
|
|
2364
2369
|
headers = request.pop("headers", None) or {}
|
|
2370
|
+
cancel = request.pop("cancel", None)
|
|
2365
2371
|
path = _data_file(os.path.expanduser(request["dir"]), request["digest"])
|
|
2366
2372
|
size = os.path.getsize(path)
|
|
2367
2373
|
piece = int(request.get("piece") or (64 << 20))
|
|
@@ -2369,6 +2375,8 @@ def _op_push_blob(request):
|
|
|
2369
2375
|
with open(path, "rb") as handle:
|
|
2370
2376
|
offset = 0
|
|
2371
2377
|
while offset < size:
|
|
2378
|
+
if cancel is not None and cancel.is_set():
|
|
2379
|
+
raise RuntimeError("the upload was abandoned")
|
|
2372
2380
|
block = handle.read(piece)
|
|
2373
2381
|
end = offset + len(block)
|
|
2374
2382
|
span = "bytes %d-%d/%d" % (offset, end - 1, size)
|
|
@@ -2390,6 +2398,98 @@ def _op_push_blob(request):
|
|
|
2390
2398
|
return {"ok": True, "value": {"id": remote_id, "size": size}}
|
|
2391
2399
|
|
|
2392
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
|
+
|
|
2393
2493
|
def _op_place_blob(request):
|
|
2394
2494
|
"""Move a received file blob to its destination path, and unpack it when asked.
|
|
2395
2495
|
|
|
@@ -2534,6 +2634,9 @@ _OPS = {
|
|
|
2534
2634
|
"pack_dir": _op_pack_dir,
|
|
2535
2635
|
"pack_blob": _op_pack_blob,
|
|
2536
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,
|
|
2537
2640
|
"place_blob": _op_place_blob,
|
|
2538
2641
|
"exec": _op_exec,
|
|
2539
2642
|
"eval": _op_eval,
|
|
@@ -2548,7 +2651,10 @@ _OPS = {
|
|
|
2548
2651
|
#: Answered by the frame reader at once, so they work while a call runs.
|
|
2549
2652
|
# ``data_failed`` is light because it must not queue behind the call it is about: the call
|
|
2550
2653
|
# is blocked on a blob the sender will never send, so a queued answer would deadlock.
|
|
2551
|
-
_LIGHT = (
|
|
2654
|
+
_LIGHT = (
|
|
2655
|
+
"stat", "lease", "reduce_peers", "drive_grant", "data_failed",
|
|
2656
|
+
"pack_push_state", "pack_push_target",
|
|
2657
|
+
)
|
|
2552
2658
|
|
|
2553
2659
|
#: Answered by several replies, the last one marked. These send their own replies.
|
|
2554
2660
|
_STREAMING = ("data_stream",)
|
|
@@ -121,6 +121,28 @@ def _access_token(stored: dict[str, Any]) -> str:
|
|
|
121
121
|
return str(json.loads(response.read())["access_token"])
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
#: Run in the CLI's own Python: release the server-side assignment at the endpoint in argv.
|
|
125
|
+
#: ``colab stop`` cannot do this, because it needs the local session state an assignment
|
|
126
|
+
#: made by a failed ``colab new`` never got. Spec "Colab", an assignment with no session.
|
|
127
|
+
UNASSIGN_SCRIPT = (
|
|
128
|
+
"import sys\n"
|
|
129
|
+
"from colab_cli.common import state\n"
|
|
130
|
+
"state.client.unassign(sys.argv[1])\n"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_assignments(listing: str) -> list[tuple[str, str]]:
|
|
135
|
+
"""The ``(name, endpoint)`` pairs in ``colab sessions`` output, ``?`` for no local state."""
|
|
136
|
+
found = []
|
|
137
|
+
for line in listing.splitlines():
|
|
138
|
+
head = line.strip().split("|")[0].split()
|
|
139
|
+
if len(head) >= 2 and head[0].startswith("[") and head[0].endswith("]"):
|
|
140
|
+
name = head[0][1:-1]
|
|
141
|
+
if name.lower() != "colab":
|
|
142
|
+
found.append((name, head[1]))
|
|
143
|
+
return found
|
|
144
|
+
|
|
145
|
+
|
|
124
146
|
class Colab(Shell):
|
|
125
147
|
"""A Colab runtime for one Google account."""
|
|
126
148
|
|
|
@@ -316,6 +338,68 @@ class Colab(Shell):
|
|
|
316
338
|
names.append(word)
|
|
317
339
|
return names
|
|
318
340
|
|
|
341
|
+
def _assignments(self) -> list[tuple[str, str]]:
|
|
342
|
+
"""Every assignment the account holds, as ``(name, endpoint)``; ``?`` has no local state."""
|
|
343
|
+
return _parse_assignments(self._cli("sessions", timeout=120))
|
|
344
|
+
|
|
345
|
+
def _unassign(self, endpoint: str) -> None:
|
|
346
|
+
"""Release one assignment by endpoint, through the CLI's own client library."""
|
|
347
|
+
uv = tools.find_uv()
|
|
348
|
+
if uv is None:
|
|
349
|
+
raise ProviderUnavailable(self.kind, tools.missing_uv_message())
|
|
350
|
+
command = [*tools.python_command(tools.COLAB, uv), "-c", UNASSIGN_SCRIPT, endpoint]
|
|
351
|
+
result = subprocess.run(
|
|
352
|
+
command, capture_output=True, text=True, timeout=180, env=self._env()
|
|
353
|
+
)
|
|
354
|
+
if result.returncode != 0:
|
|
355
|
+
raise RuntimeFailure(
|
|
356
|
+
f"unassigning {endpoint} exited {result.returncode}",
|
|
357
|
+
command=" ".join(command[:-3]),
|
|
358
|
+
stderr=(result.stderr or "").strip(),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def orphans(self) -> list[str]:
|
|
362
|
+
"""Endpoints of assignments the account holds that no local session names."""
|
|
363
|
+
return [endpoint for name, endpoint in self._assignments() if name == "?"]
|
|
364
|
+
|
|
365
|
+
def stop_orphan(self, ref: str) -> bool:
|
|
366
|
+
"""Unassign one endpoint `orphans` reported, and report whether it is gone."""
|
|
367
|
+
try:
|
|
368
|
+
self._unassign(ref)
|
|
369
|
+
except (RuntimeFailure, ProviderUnavailable, subprocess.TimeoutExpired):
|
|
370
|
+
return False
|
|
371
|
+
return True
|
|
372
|
+
|
|
373
|
+
def _release_leftovers(self, before: set[str]) -> None:
|
|
374
|
+
"""Unassign each `?` assignment that appeared since ``before``, best effort."""
|
|
375
|
+
try:
|
|
376
|
+
leftovers = [
|
|
377
|
+
endpoint
|
|
378
|
+
for name, endpoint in self._assignments()
|
|
379
|
+
if name == "?" and endpoint not in before
|
|
380
|
+
]
|
|
381
|
+
except (RuntimeFailure, ProviderUnavailable, subprocess.TimeoutExpired) as exc:
|
|
382
|
+
print(
|
|
383
|
+
f"letify: {self.alias}: the endpoint check after the failed 'colab new' "
|
|
384
|
+
f"could not list assignments ({exc}); an assignment may still be billing, "
|
|
385
|
+
"see 'letify stop --orphans'",
|
|
386
|
+
file=sys.stderr,
|
|
387
|
+
)
|
|
388
|
+
return
|
|
389
|
+
for endpoint in leftovers:
|
|
390
|
+
if self.stop_orphan(endpoint):
|
|
391
|
+
print(
|
|
392
|
+
f"letify: {self.alias}: released {endpoint}, assigned by the failed "
|
|
393
|
+
"'colab new'",
|
|
394
|
+
file=sys.stderr,
|
|
395
|
+
)
|
|
396
|
+
else:
|
|
397
|
+
print(
|
|
398
|
+
f"letify: {self.alias}: could not release {endpoint}, assigned by the "
|
|
399
|
+
"failed 'colab new'; run 'letify stop --orphans'",
|
|
400
|
+
file=sys.stderr,
|
|
401
|
+
)
|
|
402
|
+
|
|
319
403
|
# -- the pipeline ----------------------------------------------------------
|
|
320
404
|
|
|
321
405
|
def strategies(self) -> list[Strategy]:
|
|
@@ -388,7 +472,22 @@ class Colab(Shell):
|
|
|
388
472
|
args += ["--gpu", ALIASES.get(instance.gpu, instance.gpu)]
|
|
389
473
|
elif instance.tpu:
|
|
390
474
|
args += ["--tpu", instance.tpu]
|
|
391
|
-
|
|
475
|
+
try:
|
|
476
|
+
before = {endpoint for _, endpoint in self._assignments()}
|
|
477
|
+
except (RuntimeFailure, subprocess.TimeoutExpired) as exc:
|
|
478
|
+
before = None
|
|
479
|
+
print(
|
|
480
|
+
f"letify: {self.alias}: the endpoint check before 'colab new' could not "
|
|
481
|
+
f"list assignments ({exc}); a failed creation will not be cleaned up",
|
|
482
|
+
file=sys.stderr,
|
|
483
|
+
)
|
|
484
|
+
try:
|
|
485
|
+
self._cli(*args, timeout=900)
|
|
486
|
+
except BaseException:
|
|
487
|
+
# A timed out `colab new` can still leave a server-side assignment behind.
|
|
488
|
+
if before is not None:
|
|
489
|
+
self._release_leftovers(before)
|
|
490
|
+
raise
|
|
392
491
|
try:
|
|
393
492
|
self._start_keep_alive(name)
|
|
394
493
|
except BaseException:
|
|
@@ -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,
|
|
@@ -937,24 +925,167 @@ class Runtime:
|
|
|
937
925
|
return False
|
|
938
926
|
|
|
939
927
|
def _worth_packing_to_drive(self, root: str) -> bool:
|
|
940
|
-
"""Whether the build took
|
|
928
|
+
"""Whether the build took at least twice as long as restoring from Drive would.
|
|
941
929
|
|
|
942
930
|
Spec "Materializing into a runtime": the archive goes from the runtime to Drive and
|
|
943
931
|
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.
|
|
932
|
+
size over the Drive download rate plus the same size over the extraction rate. The
|
|
933
|
+
build must take ``DRIVE_PACK_FACTOR`` times that, because the pack has a cost of its
|
|
934
|
+
own that the comparison leaves out.
|
|
945
935
|
"""
|
|
946
936
|
size = self._tree_size(root)
|
|
947
937
|
if size is None or self.env_build_s is None:
|
|
948
938
|
return True
|
|
949
939
|
restore_s = size / DRIVE_DOWNLOAD_BPS + size / EXTRACT_BPS
|
|
950
|
-
if self.env_build_s
|
|
940
|
+
if self.env_build_s >= DRIVE_PACK_FACTOR * restore_s:
|
|
951
941
|
return True
|
|
952
942
|
self._say(
|
|
953
943
|
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"
|
|
944
|
+
f"and would take an estimated {restore_s:.0f} s to restore from Drive, and a "
|
|
945
|
+
f"cache pays only when the build takes at least {DRIVE_PACK_FACTOR} times as long"
|
|
955
946
|
)
|
|
956
947
|
return False
|
|
957
948
|
|
|
949
|
+
# -- the pack that runs beside the calls ---------------------------------
|
|
950
|
+
|
|
951
|
+
def _start_drive_pack(self, volume: Volume, root: str) -> None:
|
|
952
|
+
"""Have the worker pack the environment and upload it on a thread of its own.
|
|
953
|
+
|
|
954
|
+
Spec "Materializing into a runtime": this returns at once, so the first call does
|
|
955
|
+
not wait for the pack. The ref and the build record are written when a poll sees
|
|
956
|
+
the upload finish. A failure to start leaves nothing cached and says so.
|
|
957
|
+
"""
|
|
958
|
+
staging = f"{posixpath.dirname(root.rstrip('/'))}/.letify-pack"
|
|
959
|
+
try:
|
|
960
|
+
if not self.persistent_channel:
|
|
961
|
+
raise RuntimeFailure("this channel keeps no worker that could upload the archive")
|
|
962
|
+
self.request(
|
|
963
|
+
{"op": "pack_push_start", "path": root, "dir": staging}, timeout=120
|
|
964
|
+
)
|
|
965
|
+
except Exception as exc:
|
|
966
|
+
self._not_using_drive(exc, after="not caching the environment")
|
|
967
|
+
return
|
|
968
|
+
self._pack = _DrivePack(volume)
|
|
969
|
+
threading.Thread(target=self._feed_pack, name="letify-pack", daemon=True).start()
|
|
970
|
+
|
|
971
|
+
def _feed_pack(self) -> None:
|
|
972
|
+
"""Hand the pack its upload session soon after the archive is written.
|
|
973
|
+
|
|
974
|
+
A call can run for hours, and the upload cannot start until this process has made
|
|
975
|
+
the session, so this does only that. Reporting stays with the calls and the close.
|
|
976
|
+
"""
|
|
977
|
+
while True:
|
|
978
|
+
time.sleep(PACK_POLL_S)
|
|
979
|
+
job = self._pack
|
|
980
|
+
if job is None or job.target_sent or self._shut_down:
|
|
981
|
+
return
|
|
982
|
+
self._poll_pack(report=False)
|
|
983
|
+
|
|
984
|
+
def _poll_pack(self, *, report: bool = True) -> bool:
|
|
985
|
+
"""Ask the worker how the pack is doing and act on the answer.
|
|
986
|
+
|
|
987
|
+
A packed archive is given its upload session. A finished upload is recorded, and
|
|
988
|
+
reported when ``report`` is set. False when the worker could not be asked.
|
|
989
|
+
"""
|
|
990
|
+
with self._pack_lock:
|
|
991
|
+
job = self._pack
|
|
992
|
+
if job is None or self.channel is None:
|
|
993
|
+
return True
|
|
994
|
+
try:
|
|
995
|
+
state = self.request({"op": "pack_push_state"}, timeout=30)
|
|
996
|
+
except Exception:
|
|
997
|
+
return False
|
|
998
|
+
kind = state.get("state")
|
|
999
|
+
backend = job.volume.store.backend
|
|
1000
|
+
if kind == "packed" and not job.target_sent:
|
|
1001
|
+
job.target_sent = True
|
|
1002
|
+
try:
|
|
1003
|
+
if backend.has(state["digest"]):
|
|
1004
|
+
# Another session stored this archive first, so there is nothing to send.
|
|
1005
|
+
self._send_target(None)
|
|
1006
|
+
self._pack = None
|
|
1007
|
+
self._record_pack(job, state["digest"], int(state["size"]))
|
|
1008
|
+
return True
|
|
1009
|
+
target = backend.begin_upload(state["digest"], int(state["size"]))
|
|
1010
|
+
if target is None:
|
|
1011
|
+
raise RuntimeFailure(f"the {backend.name} backend offered no upload")
|
|
1012
|
+
self._send_target(target)
|
|
1013
|
+
except Exception as exc:
|
|
1014
|
+
self._pack = None
|
|
1015
|
+
try:
|
|
1016
|
+
self._send_target(None)
|
|
1017
|
+
except Exception:
|
|
1018
|
+
pass
|
|
1019
|
+
self._not_using_drive(exc, after="not caching the environment")
|
|
1020
|
+
elif kind == "done" and report:
|
|
1021
|
+
self._pack = None
|
|
1022
|
+
backend.record_upload(state["digest"], str(state["id"]))
|
|
1023
|
+
self._record_pack(job, state["digest"], int(state["size"]))
|
|
1024
|
+
seconds = float(state["seconds"])
|
|
1025
|
+
size = int(state["size"])
|
|
1026
|
+
self._say(
|
|
1027
|
+
f"cached the environment, {size / 1_000_000_000:.1f} GB in {seconds:.0f} s "
|
|
1028
|
+
f"({size / 1024**2 / max(seconds, 0.001):.1f} MiB/s)"
|
|
1029
|
+
)
|
|
1030
|
+
elif kind == "failed" and report:
|
|
1031
|
+
self._pack = None
|
|
1032
|
+
self._not_using_drive(
|
|
1033
|
+
RuntimeFailure(str(state.get("reason"))), after="not caching the environment"
|
|
1034
|
+
)
|
|
1035
|
+
elif kind == "none":
|
|
1036
|
+
self._pack = None
|
|
1037
|
+
return True
|
|
1038
|
+
|
|
1039
|
+
def _send_target(self, target: dict[str, Any] | None) -> None:
|
|
1040
|
+
"""Give the worker the upload session, or None to abandon the pack."""
|
|
1041
|
+
payload: dict[str, Any] = {"op": "pack_push_target", "url": None}
|
|
1042
|
+
if target is not None:
|
|
1043
|
+
payload.update(
|
|
1044
|
+
url=target["url"],
|
|
1045
|
+
headers=dict(target.get("headers") or {}), # type: ignore[call-overload]
|
|
1046
|
+
piece=target.get("piece"),
|
|
1047
|
+
)
|
|
1048
|
+
self.request(payload, timeout=60)
|
|
1049
|
+
|
|
1050
|
+
def _record_pack(self, job: _DrivePack, digest: str, size: int) -> None:
|
|
1051
|
+
"""Write the ref and the build record, which only a finished upload earns."""
|
|
1052
|
+
job.volume.record_env(
|
|
1053
|
+
self.env, self.platform, digest, size, build_s=self.env_build_s
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
def _close_pack(self) -> None:
|
|
1057
|
+
"""Wait for a running pack, up to ``PACK_CLOSE_WAIT_S``, then abandon it.
|
|
1058
|
+
|
|
1059
|
+
Spec "Materializing into a runtime": an abandoned pack records nothing, so the
|
|
1060
|
+
next session syncs and may pack again.
|
|
1061
|
+
"""
|
|
1062
|
+
job = self._pack
|
|
1063
|
+
if job is None:
|
|
1064
|
+
return
|
|
1065
|
+
deadline = time.monotonic() + PACK_CLOSE_WAIT_S
|
|
1066
|
+
try:
|
|
1067
|
+
while self._pack is not None:
|
|
1068
|
+
if not self._poll_pack():
|
|
1069
|
+
self._pack = None
|
|
1070
|
+
return
|
|
1071
|
+
if self._pack is None:
|
|
1072
|
+
return
|
|
1073
|
+
if time.monotonic() >= deadline:
|
|
1074
|
+
break
|
|
1075
|
+
time.sleep(0.2)
|
|
1076
|
+
with self._pack_lock:
|
|
1077
|
+
self._pack = None
|
|
1078
|
+
try:
|
|
1079
|
+
self._send_target(None)
|
|
1080
|
+
except Exception:
|
|
1081
|
+
pass
|
|
1082
|
+
self._say(
|
|
1083
|
+
"not caching the environment, because the session closed "
|
|
1084
|
+
f"{time.monotonic() - job.began:.0f} s into the pack"
|
|
1085
|
+
)
|
|
1086
|
+
except Exception:
|
|
1087
|
+
self._pack = None
|
|
1088
|
+
|
|
958
1089
|
def _restore_from(self, volume: Volume, where: dict[str, Any]) -> bool:
|
|
959
1090
|
"""Restore the environment from one volume's archive. True when the check passed.
|
|
960
1091
|
|