letify 2.5.4__tar.gz → 2.6.1__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.5.4 → letify-2.6.1}/PKG-INFO +1 -1
- {letify-2.5.4 → letify-2.6.1}/docs/SPEC.md +26 -1
- {letify-2.5.4 → letify-2.6.1}/letify/__init__.py +1 -1
- {letify-2.5.4 → letify-2.6.1}/letify/cli.py +9 -2
- {letify-2.5.4 → letify-2.6.1}/letify/runtime/bootstrap.py +12 -3
- {letify-2.5.4 → letify-2.6.1}/letify/store/pathdata.py +330 -45
- {letify-2.5.4 → letify-2.6.1}/letify-core/Cargo.lock +3 -3
- {letify-2.5.4 → letify-2.6.1}/letify-core/Cargo.toml +1 -1
- {letify-2.5.4 → letify-2.6.1}/letify-ext/package-lock.json +2 -2
- {letify-2.5.4 → letify-2.6.1}/letify-ext/package.json +1 -1
- {letify-2.5.4 → letify-2.6.1}/pyproject.toml +1 -1
- {letify-2.5.4 → letify-2.6.1}/tests/test_cli.py +14 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_parallel_placement.py +114 -1
- {letify-2.5.4 → letify-2.6.1}/tests/test_runtime.py +41 -0
- {letify-2.5.4 → letify-2.6.1}/.github/workflows/ci.yml +0 -0
- {letify-2.5.4 → letify-2.6.1}/.github/workflows/docs.yml +0 -0
- {letify-2.5.4 → letify-2.6.1}/.github/workflows/publish.yml +0 -0
- {letify-2.5.4 → letify-2.6.1}/.gitignore +0 -0
- {letify-2.5.4 → letify-2.6.1}/.researchtree +0 -0
- {letify-2.5.4 → letify-2.6.1}/CLAUDE.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/LICENSE +0 -0
- {letify-2.5.4 → letify-2.6.1}/PROJECT.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/README.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/COMPONENT.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/INTENT.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/NETWORK.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/01-getting-started.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/02-providers.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/05-concurrency.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/06-cost.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/08-releasing.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/README.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.5.4 → letify-2.6.1}/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.5.4 → letify-2.6.1}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/guide/ko/README.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/locales/README_ko.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/docs/mkdocs.yml +0 -0
- {letify-2.5.4 → letify-2.6.1}/examples/README.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.5.4 → letify-2.6.1}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/config/__init__.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/config/inventory.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/config/login.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/config/schema.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/config/secrets.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/config/writer.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/declare/__init__.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/declare/cache.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/declare/env.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/declare/function.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/declare/instance.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/declare/reduce.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/declare/replicas.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/errors.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/install.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/launcher.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/paths.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/protocol/__init__.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/protocol/codec.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/protocol/driver.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/protocol/handle.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/protocol/wire.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/protocol/worker.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/__init__.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/base.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/colab.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/colab_files.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/elice.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/kaggle.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/local.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/modal.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/modal_adapter.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/naming.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/shell.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/tunnel.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/providers/usage.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/__init__.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/capability.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/device/__init__.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/device/client.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/device/cuda.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/device/executor.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/device/frames.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/device/guard.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/device/tensor.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/device/trace.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/device/value.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/loader.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/remoting/probe.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/render.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/runtime/__init__.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/runtime/broker.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/runtime/channel.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/runtime/lease.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/runtime/pool.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/runtime/session.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/runtime/telemetry.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/store/__init__.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/store/backends/__init__.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/store/backends/filesystem.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/store/backends/google_auth.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/store/backends/layout.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/store/backends/objects.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/store/cas.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/store/sendorder.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/store/volume.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/stubs.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/tools.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/__init__.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/agent.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/announce.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/link.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/nat.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/pipeline.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/portmap.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/probe.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/quic.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/rendezvous.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/setup.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/sshopts.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify/transport/strategies.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/.gitignore +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/build.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/.vscodeignore +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/LICENSE +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/README.md +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/media/icon.svg +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/src/cli.ts +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/src/extension.ts +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/src/history.ts +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/src/model.ts +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/src/poll.ts +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/src/render.ts +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/test/model.test.ts +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify-ext/tsconfig.json +0 -0
- {letify-2.5.4 → letify-2.6.1}/letify_providers.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/scripts/sync-main.sh +0 -0
- {letify-2.5.4 → letify-2.6.1}/scripts/version.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/conftest.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/fake_modal_adapter.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_cli_output.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_colab_keepalive.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_config.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_core.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_data.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_data_streaming.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_declare.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_device.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_distributed.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_editor_sessions.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_elice.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_frames.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_install.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_kaggle.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_kaggle_link.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_link_log.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_notebook.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_paths.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_portmap.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_protocol.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_providers.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_quic.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_remoting.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_render.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_ship.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_store.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_strategies.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_stubs.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_transport.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_tunnel_setup.py +0 -0
- {letify-2.5.4 → letify-2.6.1}/tests/test_version.py +0 -0
|
@@ -316,7 +316,11 @@ Separately, and without needing a threshold at all, a refusal says how long ago
|
|
|
316
316
|
|
|
317
317
|
The record therefore notes the process as well as the ref, `<username>/<slug>` and the pid separated by a tab, so a notebook a live process is still using is never deleted. A ref whose pid is still running on this machine is reported as in use and skipped. A ref with no pid, written by a letify older than this, cannot be checked and counts as an orphan, because the record is per machine and an older letify is not running.
|
|
318
318
|
|
|
319
|
-
`letify stop` with no `--orphans` deletes nothing and says
|
|
319
|
+
`letify stop` with no `--orphans` deletes nothing and says why rather than only that: a session whose
|
|
320
|
+
creating process is still running ends when that process ends, because taking the session from under
|
|
321
|
+
it leaves it calling a runtime that is gone. So the line names the way out, `send SIGTERM to the
|
|
322
|
+
process that started it, which letify handles by closing its sessions`, and then what `--orphans` is
|
|
323
|
+
for. A bare `stop` otherwise reads as ending everything. The command lists what it will delete and asks before deleting, `-y` answers yes, and `--alias` limits it to one account. Each ref is deleted with the same CLI call `stop` uses and dropped from the record only when the delete succeeds, so a failure leaves the ref to be tried again rather than losing track of it. The exit status is 1 when any delete failed.
|
|
320
324
|
|
|
321
325
|
**Deleting the notebook, through the CLI only.** `stop` deletes the notebook with the official CLI, `kaggle kernels delete -y <username>/<slug>`, authenticated by the account's API token in `KAGGLE_API_TOKEN`; deletion of a notebook created for a cookie-authenticated interactive session is confirmed on the live account. The cookie is never asked, not even as a fallback, because deleting a notebook is not part of running or ending the interactive session. The call never fails the runtime, because the accelerator quota is already released by cancelling the run before it is tried. When it cannot run at all, for want of a token, of uv, or of the slug Kaggle did not return, or when the command itself fails, `stop` prints one warning line naming the notebook by its slug or id, never its cookie, so the user can delete it by hand on kaggle.com. The chain that starts the session, all on `https://www.kaggle.com/api/i/`, authenticated by the cookie:
|
|
322
326
|
|
|
@@ -1125,6 +1129,25 @@ So a blob above `transfer_parallel_mib`, 64 MiB by default, on a link whose meas
|
|
|
1125
1129
|
|
|
1126
1130
|
The round trip is a condition because the gain depends on it. A single stream is window limited only when the window takes a long time to fill, so on a short link it already carries everything the link will carry and the extra connections are pure cost. Measured to a lab server over forward SSH at 0.25 ms, 256 MiB went at 36.1 MiB/s on one stream and 33.5 MiB/s on four, about 7% worse, which is the cost of opening the channels and their worker processes. The link reports what the probe measured, and a link with no measurement, such as a provider fallback, is treated as short and keeps one stream. A provider whose channel is not a connection it can open more of keeps one stream too: Modal's sandbox reads and writes through its own API, and a Kaggle session that never punched is carried by a kernel bridge, so neither has a second connection to offer. A Kaggle session that did punch is reached over SSH and does: each transfer stream asks the link for its own SSH command, which lands on a local forwarding port of its own and so makes the link punch again.
|
|
1127
1131
|
|
|
1132
|
+
The connections carry different blobs at the same time as well as different ranges of one, because a
|
|
1133
|
+
call whose blobs are each under the split threshold would otherwise send them one after another and
|
|
1134
|
+
reach one connection's ceiling however many files it has. Measured on a Kaggle runtime at a 190 ms
|
|
1135
|
+
round trip, 20 files of 10 MB each went at 5.0 MiB/s on one connection, which is the 0.9 MiB window
|
|
1136
|
+
over that round trip. So the sender runs `transfer_streams` senders over the pool rather than one, and
|
|
1137
|
+
the condition for using more than one connection is the link's round trip alone: the size threshold
|
|
1138
|
+
decides whether one blob is split, not whether blobs may overlap. A blob the worker asked for by
|
|
1139
|
+
`data_want` still goes next, because the queue is reordered before a sender takes from it.
|
|
1140
|
+
|
|
1141
|
+
The connections are opened once per call and reused by every blob of it, not opened and closed per
|
|
1142
|
+
blob. A call that sends a hundred blobs over a punched link paid a hundred rounds of punching when
|
|
1143
|
+
each blob opened its own, which is both the cost the estimate below weighs and a hundred chances for
|
|
1144
|
+
a punch to be refused. They are closed when the call's transfer ends.
|
|
1145
|
+
|
|
1146
|
+
Reuse is what makes the cost worth paying, so the estimate charges it only while the connections are
|
|
1147
|
+
not yet open. The first blob large enough to want them pays the punch and weighs it; every blob after
|
|
1148
|
+
it finds them open and weighs nothing, so a blob under the size threshold still goes on one
|
|
1149
|
+
connection, but a call's second large blob is split whenever splitting is faster at all.
|
|
1150
|
+
|
|
1128
1151
|
A connection that costs something to open has to pay for itself, so a provider reports `transfer_connection_cost_s`, the seconds one more connection takes. It is 0 for a provider that dials an address, and about 10 for one reached by a hole punch, because that is what a punch measures. The streams are opened only when the transfer is expected to finish sooner with them: the estimate uses the probe's upload rate, assumes the streams scale and that their connections are opened at the same time, and keeps one stream when `size / (rate x streams) + cost` is not less than `size / rate`. A measured case: a Kaggle runtime at a 44.7 ms round trip carried 128 MiB at 21.0 MiB/s on one stream and 9.5 MiB/s on four, 2.2 times worse, because three more punches cost more than the transfer saved. The same account at a 193 ms round trip carried 1.57 MiB/s on one raw SSH connection and 6.10 MiB/s on four, so the gain is real where the link is slow enough and the blob large enough for it to be worth 10 s.
|
|
1129
1152
|
|
|
1130
1153
|
The connections have to be real ones. Every SSH command letify builds carries `ControlMaster=auto`, which multiplexes further sessions onto the first connection, so opening more channels that way would add streams inside one TCP connection and gain nothing. A transfer channel therefore sets `ControlPath=none` and `ControlMaster=no`, and a punched link redials for each, so each transfer channel is its own kernel connection.
|
|
@@ -1475,6 +1498,8 @@ For an ephemeral provider that archives its environment, the default managed Pyt
|
|
|
1475
1498
|
|
|
1476
1499
|
Otherwise the sync step sets `UV_CACHE_DIR`, `UV_PYTHON_INSTALL_DIR` and `UV_TOOL_DIR` under `<workspace root>` for `uv sync` and `uv pip install`, unconditionally: not only when the provider's `persistence` is `persistent`, and not only when it sets no `env_root`. An `Env.vars` entry naming one of the three wins over this rule for that variable.
|
|
1477
1500
|
|
|
1501
|
+
`UV_PYTHON_INSTALL_DIR` is the exception: it follows the project directory whenever the provider sets an `env_root`, because the interpreter belongs with the `.venv` it serves and a workspace root is not always a filesystem one can be unpacked on. Modal's workspace root is a Modal volume, measured as supporting no hard link at all: a link inside it fails with `EPERM` and a link from it to the sandbox disk with `EXDEV`. So `uv sync --python 3.13` on a sandbox whose image carries 3.12 downloaded a managed interpreter into the volume and failed with `error: Operation not permitted (os error 1)`, which named neither the directory nor the reason. The archive case already placed it there, for its own reason, so this makes the two agree.
|
|
1502
|
+
|
|
1478
1503
|
On a runtime with a persistent workspace root the cache outlives a container rebuild along with the projects built from it. On an ephemeral provider the cache is filled once per runtime wherever it lives and discarded with it, but it is still written under the workspace root rather than the home directory, because the workspace root may be the only writable path.
|
|
1479
1504
|
|
|
1480
1505
|
letify never deletes from the cache. The first sync on a runtime fills `<workspace root>/uv-cache` once, and a cache uv already had elsewhere is left in place. `uv cache prune` run with the same `UV_CACHE_DIR` removes entries no lock file needs any more; a file still hard linked from a `.venv` keeps its disk blocks until that `.venv` is removed as well.
|
|
@@ -755,8 +755,15 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
755
755
|
|
|
756
756
|
if args.command == "stop":
|
|
757
757
|
if not args.orphans:
|
|
758
|
-
#
|
|
759
|
-
|
|
758
|
+
# Spec "Kaggle", stopping what a dead process left behind: a bare stop reads as
|
|
759
|
+
# ending everything, so it ends nothing, and says why rather than only that.
|
|
760
|
+
print(
|
|
761
|
+
"nothing was stopped. A session whose process is still running ends when "
|
|
762
|
+
"that process ends, because taking it away leaves that process calling a "
|
|
763
|
+
"runtime that is gone: send SIGTERM to the process that started it, which "
|
|
764
|
+
"letify handles by closing its sessions. Pass --orphans to delete what a "
|
|
765
|
+
"dead process left."
|
|
766
|
+
)
|
|
760
767
|
return 0
|
|
761
768
|
rows = let.orphans(args.alias)
|
|
762
769
|
found = [(row["alias"], ref) for row in rows for ref in row.get("orphans", ())]
|
|
@@ -357,7 +357,9 @@ def sync_source(
|
|
|
357
357
|
It raises ``RuntimeError`` with ``uv could not be installed on <name>`` or ``uv sync
|
|
358
358
|
failed on <name>`` so the local side can name the step that failed.
|
|
359
359
|
"""
|
|
360
|
-
|
|
360
|
+
# Derived from the workspace root when it is given, so the two cannot disagree: the
|
|
361
|
+
# managed Python's directory is chosen by whether the project lies inside it.
|
|
362
|
+
root = root or project_dir(workspace or DEFAULT_WORKSPACE_ROOT, env)
|
|
361
363
|
workspace = workspace or DEFAULT_WORKSPACE_ROOT
|
|
362
364
|
encoded = {key: base64.b64encode(value).decode() for key, value in files.items()}
|
|
363
365
|
sync_args = sync_command(env)[1:]
|
|
@@ -400,8 +402,15 @@ def sync_source(
|
|
|
400
402
|
]
|
|
401
403
|
declared = dict(env.variables)
|
|
402
404
|
directories = uv_env(workspace)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
+
inside = root.rstrip("/") == workspace.rstrip("/") or root.startswith(
|
|
406
|
+
workspace.rstrip("/") + "/"
|
|
407
|
+
)
|
|
408
|
+
if archive or not inside:
|
|
409
|
+
# Keep an interpreter uv downloads inside the tree sent to the next VM, and, when
|
|
410
|
+
# the project directory is off the workspace root, beside the .venv it serves: a
|
|
411
|
+
# workspace root is not always a filesystem an interpreter can be unpacked on.
|
|
412
|
+
# Spec "Remote paths letify writes": Modal's is a volume that carries no hard
|
|
413
|
+
# link, so unpacking one there fails with Operation not permitted.
|
|
405
414
|
directories["UV_PYTHON_INSTALL_DIR"] = f"{root.rstrip('/')}/.letify-python"
|
|
406
415
|
for key, value in directories.items():
|
|
407
416
|
if key not in declared:
|
|
@@ -18,6 +18,7 @@ import sys
|
|
|
18
18
|
import threading
|
|
19
19
|
import time
|
|
20
20
|
import tomllib
|
|
21
|
+
from collections.abc import Callable
|
|
21
22
|
from dataclasses import dataclass, field
|
|
22
23
|
from importlib import import_module
|
|
23
24
|
from pathlib import Path
|
|
@@ -485,11 +486,16 @@ def _mib(size: int) -> str:
|
|
|
485
486
|
|
|
486
487
|
|
|
487
488
|
class _Uploaded:
|
|
488
|
-
"""Bytes sent so far, drawn as a progress line when the upload is large.
|
|
489
|
+
"""Bytes sent so far, drawn as a progress line when the upload is large.
|
|
490
|
+
|
|
491
|
+
Several senders advance this together once blobs overlap, spec "Several
|
|
492
|
+
connections at once", so ``base`` is kept under a lock rather than left to the GIL.
|
|
493
|
+
"""
|
|
489
494
|
|
|
490
495
|
def __init__(self, files: int, total: int) -> None:
|
|
491
496
|
self.base = 0
|
|
492
497
|
self.progress = None
|
|
498
|
+
self._lock = threading.Lock()
|
|
493
499
|
if total >= PROGRESS_THRESHOLD:
|
|
494
500
|
from ..install import _Progress
|
|
495
501
|
|
|
@@ -497,11 +503,14 @@ class _Uploaded:
|
|
|
497
503
|
|
|
498
504
|
def update(self, done: int) -> None:
|
|
499
505
|
if self.progress is not None:
|
|
500
|
-
self.
|
|
506
|
+
with self._lock:
|
|
507
|
+
self.progress.update(self.base + done)
|
|
501
508
|
|
|
502
509
|
def advance(self, size: int) -> None:
|
|
503
|
-
self.
|
|
504
|
-
|
|
510
|
+
with self._lock:
|
|
511
|
+
self.base += size
|
|
512
|
+
if self.progress is not None:
|
|
513
|
+
self.progress.update(self.base)
|
|
505
514
|
|
|
506
515
|
def finish(self) -> None:
|
|
507
516
|
if self.progress is not None:
|
|
@@ -628,6 +637,9 @@ class Stream:
|
|
|
628
637
|
self._token: str | None = None
|
|
629
638
|
#: The one stream every piece of this call travels on, opened at the first piece.
|
|
630
639
|
self._pipeline: Any = None
|
|
640
|
+
#: The transfer connections of this call, opened lazily at the first large blob
|
|
641
|
+
#: and reused by every one after it. Spec "Several connections at once".
|
|
642
|
+
self._channels: _Channels | None = None
|
|
631
643
|
|
|
632
644
|
# -- before the call -------------------------------------------------------
|
|
633
645
|
|
|
@@ -638,9 +650,31 @@ class Stream:
|
|
|
638
650
|
started = time.monotonic()
|
|
639
651
|
total = sum(self.plan.weight(digest)[1] for digest in wave)
|
|
640
652
|
meter = _Uploaded(len(wave), total)
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
653
|
+
senders = self._senders(len(wave))
|
|
654
|
+
if senders <= 1:
|
|
655
|
+
for digest in wave:
|
|
656
|
+
self._send(digest, meter)
|
|
657
|
+
meter.advance(self.plan.sizes.get(digest, 0))
|
|
658
|
+
else:
|
|
659
|
+
# Spec "Several connections at once": the condition for using more than one
|
|
660
|
+
# connection is the link's round trip alone, not the size threshold, so the
|
|
661
|
+
# first wave is sent with several senders too whenever the link allows it.
|
|
662
|
+
remaining = collections.deque(wave)
|
|
663
|
+
pop_lock = threading.Lock()
|
|
664
|
+
|
|
665
|
+
def pop() -> str | None:
|
|
666
|
+
with pop_lock:
|
|
667
|
+
return remaining.popleft() if remaining else None
|
|
668
|
+
|
|
669
|
+
def worker(stop: threading.Event) -> None:
|
|
670
|
+
while not stop.is_set():
|
|
671
|
+
digest = pop()
|
|
672
|
+
if digest is None:
|
|
673
|
+
return
|
|
674
|
+
self._send(digest, meter, parallel=True)
|
|
675
|
+
meter.advance(self.plan.sizes.get(digest, 0))
|
|
676
|
+
|
|
677
|
+
self._send_parallel(senders, worker)
|
|
644
678
|
# Every piece answered, so the wave is placed when the call goes out.
|
|
645
679
|
self._flush()
|
|
646
680
|
meter.finish()
|
|
@@ -659,9 +693,26 @@ class Stream:
|
|
|
659
693
|
def _run(self) -> None:
|
|
660
694
|
started = time.monotonic()
|
|
661
695
|
try:
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
696
|
+
with self._lock:
|
|
697
|
+
left = len(self._queue)
|
|
698
|
+
senders = self._senders(left)
|
|
699
|
+
if senders <= 1:
|
|
700
|
+
while not self._cancelled.is_set():
|
|
701
|
+
if not self._pump():
|
|
702
|
+
break
|
|
703
|
+
else:
|
|
704
|
+
# Spec "Several connections at once": several senders over the pool,
|
|
705
|
+
# each reordering and taking from the same queue the single sender
|
|
706
|
+
# would have used, rather than one sender on the call's own stream.
|
|
707
|
+
def worker(stop: threading.Event) -> None:
|
|
708
|
+
while not self._cancelled.is_set() and not stop.is_set():
|
|
709
|
+
self.reorder()
|
|
710
|
+
digest = self._take()
|
|
711
|
+
if digest is None:
|
|
712
|
+
return
|
|
713
|
+
self._send(digest, None, parallel=True)
|
|
714
|
+
|
|
715
|
+
self._send_parallel(senders, worker)
|
|
665
716
|
# The pieces still in flight are answered before the stream is released.
|
|
666
717
|
self._flush()
|
|
667
718
|
except Exception as exc:
|
|
@@ -741,6 +792,8 @@ class Stream:
|
|
|
741
792
|
if (thread is None or not thread.is_alive()) and self._pipeline is not None:
|
|
742
793
|
self._pipeline.close()
|
|
743
794
|
self._pipeline = None
|
|
795
|
+
if self._channels is not None:
|
|
796
|
+
self._channels.close()
|
|
744
797
|
self._drop_partials()
|
|
745
798
|
|
|
746
799
|
def _drop_partials(self) -> None:
|
|
@@ -766,6 +819,14 @@ class Stream:
|
|
|
766
819
|
self._pipeline = self.runtime.pipeline(window=WINDOW, timeout=3600)
|
|
767
820
|
return self._pipeline
|
|
768
821
|
|
|
822
|
+
def _pool(self) -> _Channels:
|
|
823
|
+
"""The call's transfer connections, opened lazily and reused by every blob."""
|
|
824
|
+
with self._lock:
|
|
825
|
+
if self._channels is None:
|
|
826
|
+
limit = int(getattr(self.runtime.provider, "transfer_streams", STREAMS) or 1)
|
|
827
|
+
self._channels = _Channels(self.runtime, limit)
|
|
828
|
+
return self._channels
|
|
829
|
+
|
|
769
830
|
def _flush(self) -> None:
|
|
770
831
|
"""Read every outstanding reply, so a piece the worker refused fails here."""
|
|
771
832
|
if self._pipeline is None:
|
|
@@ -775,9 +836,79 @@ class Stream:
|
|
|
775
836
|
except RemoteError as exc:
|
|
776
837
|
raise RuntimeFailure(f"{self.runtime.name}: data_put failed: {exc}") from exc
|
|
777
838
|
|
|
839
|
+
# -- several senders at once -------------------------------------------------
|
|
840
|
+
|
|
841
|
+
def _senders(self, available: int) -> int:
|
|
842
|
+
"""How many blobs this call may have in flight at once, never above ``available``.
|
|
843
|
+
|
|
844
|
+
Spec "Several connections at once": the connections carry different blobs at the
|
|
845
|
+
same time as well as different ranges of one, and the condition for more than one
|
|
846
|
+
is the link's round trip alone. The size threshold decides whether one blob is
|
|
847
|
+
split, not whether blobs may overlap.
|
|
848
|
+
"""
|
|
849
|
+
provider = getattr(self.runtime, "provider", None)
|
|
850
|
+
if not _has_transfer_channel(provider):
|
|
851
|
+
return 1
|
|
852
|
+
rtt = _measured_rtt(provider, self.runtime)
|
|
853
|
+
if rtt is None or rtt < PARALLEL_RTT_MS:
|
|
854
|
+
return 1
|
|
855
|
+
limit = int(getattr(provider, "transfer_streams", STREAMS) or 1)
|
|
856
|
+
return max(1, min(limit, available))
|
|
857
|
+
|
|
858
|
+
def _send_parallel(self, count: int, worker: Callable[[threading.Event], None]) -> None:
|
|
859
|
+
"""Run ``worker(stop)`` on ``count`` threads; the first failure stops the rest.
|
|
860
|
+
|
|
861
|
+
One worker failing must not leave the others sending into a dead transfer, so
|
|
862
|
+
``stop`` is set as soon as any of them raises, and the first failure is what
|
|
863
|
+
reaches the caller once every thread has joined.
|
|
864
|
+
"""
|
|
865
|
+
failures: list[BaseException] = []
|
|
866
|
+
failures_lock = threading.Lock()
|
|
867
|
+
stop = threading.Event()
|
|
868
|
+
|
|
869
|
+
def guarded() -> None:
|
|
870
|
+
try:
|
|
871
|
+
worker(stop)
|
|
872
|
+
except BaseException as exc:
|
|
873
|
+
with failures_lock:
|
|
874
|
+
failures.append(exc)
|
|
875
|
+
stop.set()
|
|
876
|
+
|
|
877
|
+
threads = [
|
|
878
|
+
threading.Thread(target=guarded, name=f"letify-data-{index}", daemon=True)
|
|
879
|
+
for index in range(count)
|
|
880
|
+
]
|
|
881
|
+
for thread in threads:
|
|
882
|
+
thread.start()
|
|
883
|
+
for thread in threads:
|
|
884
|
+
thread.join()
|
|
885
|
+
if failures:
|
|
886
|
+
raise failures[0]
|
|
887
|
+
|
|
888
|
+
def _send_alone(self, action: Callable[[Any], None]) -> None:
|
|
889
|
+
"""Run ``action(pipeline)`` on a pooled channel held for the call it makes.
|
|
890
|
+
|
|
891
|
+
Used when several senders share the call's pool: ``self._link()`` carries only
|
|
892
|
+
one sender's ordering at a time, so a blob that needs a pipeline for more than
|
|
893
|
+
one request borrows a channel of its own for as long as that takes. Spec
|
|
894
|
+
"Several connections at once".
|
|
895
|
+
"""
|
|
896
|
+
channel = self._pool().take()
|
|
897
|
+
ok = False
|
|
898
|
+
try:
|
|
899
|
+
pipeline = channel.pipeline(window=WINDOW, timeout=3600)
|
|
900
|
+
action(pipeline)
|
|
901
|
+
pipeline.drain()
|
|
902
|
+
ok = True
|
|
903
|
+
finally:
|
|
904
|
+
if ok:
|
|
905
|
+
self._pool().give(channel)
|
|
906
|
+
else:
|
|
907
|
+
self._pool().drop(channel)
|
|
908
|
+
|
|
778
909
|
# -- one blob ---------------------------------------------------------------
|
|
779
910
|
|
|
780
|
-
def _send(self, digest: str, meter: _Uploaded | None) -> None:
|
|
911
|
+
def _send(self, digest: str, meter: _Uploaded | None, *, parallel: bool = False) -> None:
|
|
781
912
|
with self._lock:
|
|
782
913
|
if digest in self._sent or self._cancelled.is_set():
|
|
783
914
|
return
|
|
@@ -787,10 +918,48 @@ class Stream:
|
|
|
787
918
|
bucket = self.plan.bucket
|
|
788
919
|
uploaded = True
|
|
789
920
|
if bucket is None:
|
|
790
|
-
|
|
791
|
-
|
|
921
|
+
# The cost of opening the connections is weighed only while the pool is cold:
|
|
922
|
+
# the first blob large enough to want them pays the punch, every one after it
|
|
923
|
+
# finds them open. Spec "Several connections at once".
|
|
924
|
+
warm = self._channels is not None and self._channels.warm
|
|
925
|
+
streams = _streams_for(self.runtime, size, warm=warm)
|
|
926
|
+
if streams > 1 and parallel:
|
|
927
|
+
# self._link() carries only one sender's ordering at a time, so the
|
|
928
|
+
# data_begin/data_seal pair this blob needs borrows a pooled channel of
|
|
929
|
+
# its own instead, the same way the ranges already do.
|
|
930
|
+
self._send_alone(
|
|
931
|
+
lambda pipeline: _put_parallel(
|
|
932
|
+
self.runtime,
|
|
933
|
+
pipeline,
|
|
934
|
+
self.blobs,
|
|
935
|
+
digest,
|
|
936
|
+
local,
|
|
937
|
+
size,
|
|
938
|
+
meter,
|
|
939
|
+
streams,
|
|
940
|
+
self._pool(),
|
|
941
|
+
)
|
|
942
|
+
)
|
|
943
|
+
elif streams > 1:
|
|
792
944
|
_put_parallel(
|
|
793
|
-
self.runtime,
|
|
945
|
+
self.runtime,
|
|
946
|
+
self._link(),
|
|
947
|
+
self.blobs,
|
|
948
|
+
digest,
|
|
949
|
+
local,
|
|
950
|
+
size,
|
|
951
|
+
meter,
|
|
952
|
+
streams,
|
|
953
|
+
self._pool(),
|
|
954
|
+
)
|
|
955
|
+
elif parallel:
|
|
956
|
+
# Not split, but two senders cannot share self._link()'s ordering
|
|
957
|
+
# either, so this blob takes a pooled channel of its own for its
|
|
958
|
+
# whole send. Spec "Several connections at once".
|
|
959
|
+
self._send_alone(
|
|
960
|
+
lambda pipeline: _put(
|
|
961
|
+
self.runtime, pipeline, self.blobs, digest, local, size, meter
|
|
962
|
+
)
|
|
794
963
|
)
|
|
795
964
|
else:
|
|
796
965
|
_put(self.runtime, self._link(), self.blobs, digest, local, size, meter)
|
|
@@ -816,12 +985,12 @@ class Stream:
|
|
|
816
985
|
files, total = self.plan.weight(digest)
|
|
817
986
|
with self._lock:
|
|
818
987
|
self.done.add(digest)
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
988
|
+
if self._thread is None:
|
|
989
|
+
self.before_files += files
|
|
990
|
+
self.before_bytes += total
|
|
991
|
+
else:
|
|
992
|
+
self.during_files += files
|
|
993
|
+
self.during_bytes += total
|
|
825
994
|
|
|
826
995
|
@property
|
|
827
996
|
def sent(self) -> int:
|
|
@@ -903,37 +1072,137 @@ def streams_for(
|
|
|
903
1072
|
return count
|
|
904
1073
|
|
|
905
1074
|
|
|
906
|
-
def
|
|
907
|
-
"""
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
except Exception:
|
|
921
|
-
rtt = None
|
|
922
|
-
rate = None
|
|
1075
|
+
def _has_transfer_channel(provider: Any) -> bool:
|
|
1076
|
+
"""Whether the provider's class can open another transfer connection.
|
|
1077
|
+
|
|
1078
|
+
On the class, not the instance: a provider's ``__getattr__`` answers accelerator
|
|
1079
|
+
names and raises ``UnknownInstance`` for anything else, which ``hasattr`` does not
|
|
1080
|
+
catch. A provider whose channel is not a connection it can open more of, such as
|
|
1081
|
+
Modal's sandbox pipes or Kaggle's kernel bridge, answers False. Spec "Several
|
|
1082
|
+
connections at once".
|
|
1083
|
+
"""
|
|
1084
|
+
return hasattr(type(provider), "transfer_channel")
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _measured_link(provider: Any, runtime: Any) -> Any:
|
|
1088
|
+
"""The provider's link for ``runtime``, or None where asking for it fails."""
|
|
923
1089
|
try:
|
|
924
|
-
|
|
1090
|
+
return provider.link(runtime) if provider is not None else None
|
|
925
1091
|
except Exception:
|
|
926
|
-
|
|
1092
|
+
return None
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _measured_rtt(provider: Any, runtime: Any) -> float | None:
|
|
1096
|
+
"""The link's measured round trip in milliseconds, or None with no measurement."""
|
|
1097
|
+
return getattr(_measured_link(provider, runtime), "rtt_ms", None)
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _streams_for(runtime: Any, size: int, *, warm: bool = False) -> int:
|
|
1101
|
+
"""``streams_for`` with the account's settings and the link's measured round trip.
|
|
1102
|
+
|
|
1103
|
+
``warm`` is True once the call's transfer connections are already open, so there is
|
|
1104
|
+
nothing left to pay for opening them. Spec "Several connections at once": reuse is
|
|
1105
|
+
what makes the cost worth paying, so the estimate charges it only while the pool is
|
|
1106
|
+
cold.
|
|
1107
|
+
"""
|
|
1108
|
+
provider = getattr(runtime, "provider", None)
|
|
1109
|
+
if not _has_transfer_channel(provider):
|
|
1110
|
+
return 1
|
|
1111
|
+
link = _measured_link(provider, runtime)
|
|
1112
|
+
rtt = getattr(link, "rtt_ms", None)
|
|
1113
|
+
rate = getattr(link, "upload_bps", None)
|
|
1114
|
+
cost_s = 0.0 if warm else float(getattr(provider, "transfer_connection_cost_s", 0.0) or 0.0)
|
|
927
1115
|
return streams_for(
|
|
928
1116
|
streams=int(getattr(provider, "transfer_streams", STREAMS) or 1),
|
|
929
1117
|
threshold_mib=int(getattr(provider, "transfer_parallel_mib", PARALLEL_MIB) or 0),
|
|
930
1118
|
rate_bps=rate,
|
|
931
|
-
cost_s=
|
|
1119
|
+
cost_s=cost_s,
|
|
932
1120
|
rtt_ms=rtt,
|
|
933
1121
|
size=size,
|
|
934
1122
|
)
|
|
935
1123
|
|
|
936
1124
|
|
|
1125
|
+
class _Channels:
|
|
1126
|
+
"""The transfer connections of one call, opened once and reused by every blob of it.
|
|
1127
|
+
|
|
1128
|
+
Spec "Several connections at once": the connections are opened once per call and
|
|
1129
|
+
reused by every blob of it, not opened and closed per blob, which is both the cost the
|
|
1130
|
+
estimate weighs and a chance for a punch to be refused each time it happens. Used from
|
|
1131
|
+
several threads at once, one per range of a parallel put.
|
|
1132
|
+
"""
|
|
1133
|
+
|
|
1134
|
+
def __init__(self, runtime: Any, limit: int) -> None:
|
|
1135
|
+
self._runtime = runtime
|
|
1136
|
+
self._limit = max(1, int(limit))
|
|
1137
|
+
self._free: list[Any] = []
|
|
1138
|
+
self._opened = 0
|
|
1139
|
+
self._closed = False
|
|
1140
|
+
self._lock = threading.Lock()
|
|
1141
|
+
self._condition = threading.Condition(self._lock)
|
|
1142
|
+
|
|
1143
|
+
@property
|
|
1144
|
+
def warm(self) -> bool:
|
|
1145
|
+
"""True once at least one channel of this pool has been opened."""
|
|
1146
|
+
with self._lock:
|
|
1147
|
+
return self._opened > 0
|
|
1148
|
+
|
|
1149
|
+
def take(self) -> Any:
|
|
1150
|
+
"""A free channel, a newly opened one, or the next one ``give``n back.
|
|
1151
|
+
|
|
1152
|
+
Raises RuntimeFailure, naming the runtime, if the pool is already closed. Never
|
|
1153
|
+
hangs past a ``close()`` that arrives while this call is waiting.
|
|
1154
|
+
"""
|
|
1155
|
+
with self._condition:
|
|
1156
|
+
while True:
|
|
1157
|
+
if self._closed:
|
|
1158
|
+
raise RuntimeFailure(
|
|
1159
|
+
f"{self._runtime.name}: the transfer pool is closed"
|
|
1160
|
+
)
|
|
1161
|
+
if self._free:
|
|
1162
|
+
return self._free.pop()
|
|
1163
|
+
if self._opened < self._limit:
|
|
1164
|
+
self._opened += 1
|
|
1165
|
+
break
|
|
1166
|
+
self._condition.wait()
|
|
1167
|
+
try:
|
|
1168
|
+
return self._runtime.provider.transfer_channel(self._runtime)
|
|
1169
|
+
except BaseException:
|
|
1170
|
+
# The slot was reserved before opening, and must be released on failure so a
|
|
1171
|
+
# waiter is not left blocked on a channel that never came to exist.
|
|
1172
|
+
with self._condition:
|
|
1173
|
+
self._opened -= 1
|
|
1174
|
+
self._condition.notify_all()
|
|
1175
|
+
raise
|
|
1176
|
+
|
|
1177
|
+
def give(self, channel: Any) -> None:
|
|
1178
|
+
"""Return a channel to the free list, for the next ``take()`` to reuse."""
|
|
1179
|
+
with self._condition:
|
|
1180
|
+
if self._closed:
|
|
1181
|
+
closed_already = True
|
|
1182
|
+
else:
|
|
1183
|
+
self._free.append(channel)
|
|
1184
|
+
self._condition.notify_all()
|
|
1185
|
+
closed_already = False
|
|
1186
|
+
if closed_already:
|
|
1187
|
+
channel.close()
|
|
1188
|
+
|
|
1189
|
+
def drop(self, channel: Any) -> None:
|
|
1190
|
+
"""A channel that failed: close it and free its slot for a replacement."""
|
|
1191
|
+
channel.close()
|
|
1192
|
+
with self._condition:
|
|
1193
|
+
self._opened = max(0, self._opened - 1)
|
|
1194
|
+
self._condition.notify_all()
|
|
1195
|
+
|
|
1196
|
+
def close(self) -> None:
|
|
1197
|
+
"""Close every channel this pool opened, and refuse any ``take()`` after it."""
|
|
1198
|
+
with self._condition:
|
|
1199
|
+
self._closed = True
|
|
1200
|
+
free, self._free = self._free, []
|
|
1201
|
+
self._condition.notify_all()
|
|
1202
|
+
for channel in free:
|
|
1203
|
+
channel.close()
|
|
1204
|
+
|
|
1205
|
+
|
|
937
1206
|
def _put_parallel(
|
|
938
1207
|
runtime: Any,
|
|
939
1208
|
pipeline: Any,
|
|
@@ -943,12 +1212,15 @@ def _put_parallel(
|
|
|
943
1212
|
size: int,
|
|
944
1213
|
meter: _Uploaded | None,
|
|
945
1214
|
streams: int,
|
|
1215
|
+
channels: _Channels,
|
|
946
1216
|
) -> None:
|
|
947
1217
|
"""Send one file over several connections, each carrying one contiguous range.
|
|
948
1218
|
|
|
949
1219
|
Spec "Several connections at once". The call's own stream makes the file and seals it,
|
|
950
1220
|
so a failure there is reported where every other data failure is. The ranges go out on
|
|
951
|
-
|
|
1221
|
+
the call's pooled transfer connections, each its own kernel connection, taken from
|
|
1222
|
+
``channels`` and given back when the range finishes so the next blob of this call
|
|
1223
|
+
reuses it rather than opening its own.
|
|
952
1224
|
"""
|
|
953
1225
|
split = ranges(size, streams)
|
|
954
1226
|
try:
|
|
@@ -962,9 +1234,14 @@ def _put_parallel(
|
|
|
962
1234
|
lock = threading.Lock()
|
|
963
1235
|
|
|
964
1236
|
def carry(index: int, offset: int, length: int) -> None:
|
|
965
|
-
channel = None
|
|
966
1237
|
try:
|
|
967
|
-
channel =
|
|
1238
|
+
channel = channels.take()
|
|
1239
|
+
except BaseException as exc:
|
|
1240
|
+
with lock:
|
|
1241
|
+
failures.append(exc)
|
|
1242
|
+
return
|
|
1243
|
+
ok = False
|
|
1244
|
+
try:
|
|
968
1245
|
stream = channel.pipeline(window=WINDOW, timeout=3600)
|
|
969
1246
|
with open(local, "rb") as handle:
|
|
970
1247
|
handle.seek(offset)
|
|
@@ -991,12 +1268,19 @@ def _put_parallel(
|
|
|
991
1268
|
meter.update(sum(sent))
|
|
992
1269
|
stream.drain()
|
|
993
1270
|
stream.close()
|
|
1271
|
+
ok = True
|
|
994
1272
|
except BaseException as exc:
|
|
995
1273
|
with lock:
|
|
996
1274
|
failures.append(exc)
|
|
997
1275
|
finally:
|
|
998
|
-
|
|
999
|
-
|
|
1276
|
+
# A range that finished cleanly gives the channel back for the next blob of
|
|
1277
|
+
# this call to reuse. One that failed partway cannot be trusted to still be
|
|
1278
|
+
# at a clean protocol boundary, so it is closed instead and the pool opens a
|
|
1279
|
+
# replacement next time one is needed.
|
|
1280
|
+
if ok:
|
|
1281
|
+
channels.give(channel)
|
|
1282
|
+
else:
|
|
1283
|
+
channels.drop(channel)
|
|
1000
1284
|
|
|
1001
1285
|
workers = [
|
|
1002
1286
|
threading.Thread(target=carry, args=(index, offset, length), daemon=True)
|
|
@@ -1080,6 +1364,7 @@ class _Downloaded(_Uploaded):
|
|
|
1080
1364
|
def __init__(self, files: int, total: int) -> None:
|
|
1081
1365
|
self.base = 0
|
|
1082
1366
|
self.progress = None
|
|
1367
|
+
self._lock = threading.Lock()
|
|
1083
1368
|
if total >= PROGRESS_THRESHOLD:
|
|
1084
1369
|
from ..install import _Progress
|
|
1085
1370
|
|
|
@@ -138,7 +138,7 @@ dependencies = [
|
|
|
138
138
|
|
|
139
139
|
[[package]]
|
|
140
140
|
name = "letify-agent"
|
|
141
|
-
version = "2.
|
|
141
|
+
version = "2.6.1"
|
|
142
142
|
dependencies = [
|
|
143
143
|
"letify-wire",
|
|
144
144
|
"libloading",
|
|
@@ -146,7 +146,7 @@ dependencies = [
|
|
|
146
146
|
|
|
147
147
|
[[package]]
|
|
148
148
|
name = "letify-driver"
|
|
149
|
-
version = "2.
|
|
149
|
+
version = "2.6.1"
|
|
150
150
|
dependencies = [
|
|
151
151
|
"letify-wire",
|
|
152
152
|
]
|
|
@@ -164,7 +164,7 @@ dependencies = [
|
|
|
164
164
|
|
|
165
165
|
[[package]]
|
|
166
166
|
name = "letify-wire"
|
|
167
|
-
version = "2.
|
|
167
|
+
version = "2.6.1"
|
|
168
168
|
|
|
169
169
|
[[package]]
|
|
170
170
|
name = "libc"
|