flashruntime 0.5.0__tar.gz → 0.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.
- {flashruntime-0.5.0/flashruntime.egg-info → flashruntime-0.6.1}/PKG-INFO +1 -1
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/leases/manager.py +181 -10
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/leases/sqlite_store.py +39 -5
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/leases/store.py +24 -1
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/protocol/v1alpha1.py +17 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recipes/command.py +53 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/scheduler/__init__.py +92 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/checkpoints.py +49 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/modea.py +139 -4
- {flashruntime-0.5.0 → flashruntime-0.6.1/flashruntime.egg-info}/PKG-INFO +1 -1
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime.egg-info/SOURCES.txt +4 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/pyproject.toml +9 -1
- flashruntime-0.6.1/tests/test_leases.py +425 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_leases_sqlite.py +77 -3
- flashruntime-0.6.1/tests/test_placement_datasets.py +354 -0
- flashruntime-0.6.1/tests/test_protocol_datasets.py +28 -0
- flashruntime-0.6.1/tests/test_protocol_lease_fields.py +48 -0
- flashruntime-0.6.1/tests/test_recipe_datasets.py +95 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_recipes_command.py +31 -0
- flashruntime-0.6.1/tests/test_service_checkpoints.py +536 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_delegation.py +39 -12
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_modea.py +120 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_write_scope.py +16 -3
- flashruntime-0.5.0/tests/test_leases.py +0 -204
- flashruntime-0.5.0/tests/test_service_checkpoints.py +0 -122
- {flashruntime-0.5.0 → flashruntime-0.6.1}/LICENSE +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/README.md +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/chunks.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/fedavg_driver.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/fedavg_weights.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/fedavg_worker.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/kmeans_driver.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/kmeans_shard.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/sgd_trainer.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/sharded_kmeans.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/sklearn_trial.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/artifacts/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/artifacts/store.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/backends/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/backends/base.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/backends/kuberay.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/checkpoint/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/checkpoint/catalog.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/checkpoint/local.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/checkpoint/store.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/envelope.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/images/TAG +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/images/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/images/python-slim.txt +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/images/pytorch-cpu.txt +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/images/pytorch-cuda.txt +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/images/sklearn.txt +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/integrations/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/integrations/huggingface.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/integrations/pytorch.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/integrations/sklearn.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/launchers/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/launchers/local.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/leases/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/monitor/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/monitor/sampler.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/candidates.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/catalog.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/comm.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/explain.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/memory.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/resolve.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/selector.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/timecost.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/profiling/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/protocol/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/protocol/plan_v1alpha1.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/providers/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/py.typed +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recipes/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recipes/partition.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recovery/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recovery/policy.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recovery/signals.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recovery/taxonomy.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/reduce.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/retention.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/sdk.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/app.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/auth.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/cli.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/dashboard.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/ledger.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/strategies/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/strategies/command.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/torch/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/validators.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/benchmarks.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/get-started.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/index.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/cli.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/search-index.json +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/flowmap.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/page.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/server.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/state.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/workloads/__init__.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/workloads/command.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime.egg-info/dependency_links.txt +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime.egg-info/entry_points.txt +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime.egg-info/requires.txt +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime.egg-info/top_level.txt +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/setup.cfg +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_auto_recovery.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_benchmarks.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_checkpoint.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_checkpoint_local.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_chunks.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_cli_submit.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_cost_benchmark.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_declared_validators.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_documentation.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_envelope.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_examples_e2e.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_fedavg_convergence.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_fedavg_driver.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_fedavg_weights.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_fedavg_worker.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_gpu_e2e.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_headline.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_image_manifest_lookup.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_integrations.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_interfaces.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_job_state_partial.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_kmeans_leases.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_kmeans_workload.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_kuberay_backend.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_launcher_local.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_lease_seconds_bound.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_leases_scope.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_monitor_sampler.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_dependencies.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_exclusion.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_gpu.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_local_data.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_pool.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_reliability.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_trusted_argv.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_planner.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_protocol_evidence.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_protocol_gpu.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_protocol_local_datasets.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_protocol_pools.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_protocol_v1alpha1.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_recipe_gpu.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_recipe_local_inputs.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_recipe_partition.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_recipe_pool.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_recovery.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_reduce.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_retention.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_run_json.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_scheduler_isolation.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_sdk_submit.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_artifact_delete.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_auth.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_auth_startup.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_command_recipe.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_fedavg.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_job_result.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_reliability.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_validate_output.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_sgd_trainer.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_strategy_command.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_submit_envelope.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_submit_retention.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_torch_helper.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_viewer_flowmap.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_viewer_page.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_viewer_server.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_viewer_state.py +0 -0
- {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_workloads_command.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flashruntime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Open fault-tolerant distributed ML runtime: strategy planning, job protocol, leases, checkpointing, and recovery across heterogeneous compute.
|
|
5
5
|
Author: Zolli Labs
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -18,9 +18,26 @@ State transitions (task):
|
|
|
18
18
|
|
|
19
19
|
PENDING ──claim──▶ LEASED ──complete (first valid)──▶ COMPLETED
|
|
20
20
|
▲ │
|
|
21
|
-
└──sweep expiry / fail (
|
|
22
|
-
LEASED ──fail
|
|
21
|
+
└──sweep expiry / fail / revoked-silent / force_expire (budget left)──┘
|
|
22
|
+
LEASED ──fail, failure budget spent──▶ FAILED
|
|
23
|
+
LEASED ──expiry, expiry budget spent──▶ FAILED
|
|
23
24
|
any non-terminal ──cancel──▶ CANCELLED
|
|
25
|
+
|
|
26
|
+
Failure and expiry spend separate budgets (audit C12): a worker that
|
|
27
|
+
reports N real failures exhausts at `max_attempts`; a lease that lapses
|
|
28
|
+
unclaimed N times (pure churn — no worker ever ran it) exhausts at the
|
|
29
|
+
separate, more generous `max_expiries`. `attempts_used` still numbers
|
|
30
|
+
attempts but decides nothing terminal on its own.
|
|
31
|
+
|
|
32
|
+
Two things can end a lease early besides its deadline (audit C11/§5.1).
|
|
33
|
+
The *progress watchdog*: a heartbeat proves a process is alive, not that
|
|
34
|
+
work is happening, so a holder silent past `spec.max_silent_seconds` has
|
|
35
|
+
its next renewal refused and the task requeued — `note_progress` is what
|
|
36
|
+
resets that clock. It is opt-in (`max_silent_seconds` defaults to 0, which
|
|
37
|
+
disables it): a task that reports no progress signal is not thereby stuck,
|
|
38
|
+
so whoever expands a job arms the window only for work that does report.
|
|
39
|
+
And `force_expire`, the operator's manual version of the same revocation.
|
|
40
|
+
Both spend the expiry budget, because both are churn.
|
|
24
41
|
"""
|
|
25
42
|
|
|
26
43
|
from __future__ import annotations
|
|
@@ -80,6 +97,11 @@ class LeaseManager:
|
|
|
80
97
|
record.state = TaskState.CANCELLED
|
|
81
98
|
record.active_lease = None
|
|
82
99
|
self._store.save(record)
|
|
100
|
+
# Emitted only on a real transition (audit §5.1): the ledger is the
|
|
101
|
+
# only place a cancellation was ever visible, and a task that
|
|
102
|
+
# vanished from the queue with no event read as a coordinator bug.
|
|
103
|
+
# An idempotent re-cancel transitions nothing, so it says nothing.
|
|
104
|
+
self._emit(EventType.TASK_CANCELLED, job_id, task_id, now=now)
|
|
83
105
|
|
|
84
106
|
# -- worker side --------------------------------------------------------
|
|
85
107
|
|
|
@@ -125,6 +147,7 @@ class LeaseManager:
|
|
|
125
147
|
if record is None:
|
|
126
148
|
return None
|
|
127
149
|
record.attempts_used += 1
|
|
150
|
+
record.last_progress_at = now
|
|
128
151
|
lease = Lease(
|
|
129
152
|
lease_id=f"ls-{uuid.uuid4().hex[:12]}",
|
|
130
153
|
task_id=record.spec.task_id,
|
|
@@ -132,6 +155,9 @@ class LeaseManager:
|
|
|
132
155
|
node_id=node_id,
|
|
133
156
|
attempt_number=record.attempts_used,
|
|
134
157
|
deadline=now + timedelta(seconds=record.spec.lease_seconds),
|
|
158
|
+
# On the wire so the holder can pick its own heartbeat interval
|
|
159
|
+
# from the real window instead of guessing one (audit §5.1).
|
|
160
|
+
lease_seconds=record.spec.lease_seconds,
|
|
135
161
|
payload=record.spec.payload,
|
|
136
162
|
)
|
|
137
163
|
record.state = TaskState.LEASED
|
|
@@ -151,9 +177,38 @@ class LeaseManager:
|
|
|
151
177
|
def heartbeat(self, lease_id: str, now: datetime | None = None) -> Lease:
|
|
152
178
|
"""Renew a live lease (extends the deadline by the task's lease
|
|
153
179
|
window). Refused for unknown/expired/superseded leases — a worker
|
|
154
|
-
whose heartbeat is refused must stop working on the task.
|
|
180
|
+
whose heartbeat is refused must stop working on the task.
|
|
181
|
+
|
|
182
|
+
Also refused, and the lease revoked, when the holder has been alive
|
|
183
|
+
but *silent* past `spec.max_silent_seconds` (audit C11). A heartbeat
|
|
184
|
+
proves a process is running; only `note_progress` proves the work
|
|
185
|
+
is. Without this, a stuck worker whose heartbeat thread survived
|
|
186
|
+
renewed its lease forever and the task never came back — the zombie
|
|
187
|
+
lease. Opt-in: `max_silent_seconds` defaults to 0, which disables
|
|
188
|
+
the watchdog, so it never fires on a workload that reports nothing.
|
|
189
|
+
"""
|
|
155
190
|
now = now or _utcnow()
|
|
156
191
|
record, lease = self._require_live_lease(lease_id, now)
|
|
192
|
+
# After the liveness check on purpose: an already-dead lease is
|
|
193
|
+
# refused as dead ("no longer live"), not as silent. Only a lease
|
|
194
|
+
# that is still good takes the revocation path below.
|
|
195
|
+
window = record.spec.max_silent_seconds
|
|
196
|
+
baseline = record.last_progress_at
|
|
197
|
+
if window > 0 and baseline is not None and (now - baseline).total_seconds() > window:
|
|
198
|
+
self._emit(
|
|
199
|
+
EventType.LEASE_RENEWAL_REFUSED,
|
|
200
|
+
lease.job_id,
|
|
201
|
+
lease.task_id,
|
|
202
|
+
node_id=lease.node_id,
|
|
203
|
+
detail=f"no progress for {(now - baseline).total_seconds():.0f}s "
|
|
204
|
+
f"(budget {window:.0f}s) — lease revoked, task requeued",
|
|
205
|
+
now=now,
|
|
206
|
+
)
|
|
207
|
+
# Counter bumped at the call site, like fail()/sweep() (audit
|
|
208
|
+
# C12): a revoked lease is churn, so it spends the expiry budget.
|
|
209
|
+
record.expiries_seen += 1
|
|
210
|
+
self._release(record, now, EventType.TASK_REQUEUED, budget="expiry")
|
|
211
|
+
raise LeaseError(f"lease {lease_id} revoked: no progress within {window:.0f}s")
|
|
157
212
|
renewed = lease.model_copy(
|
|
158
213
|
update={"deadline": now + timedelta(seconds=record.spec.lease_seconds)}
|
|
159
214
|
)
|
|
@@ -178,6 +233,13 @@ class LeaseManager:
|
|
|
178
233
|
"""
|
|
179
234
|
now = now or _utcnow()
|
|
180
235
|
record, lease = self._find_lease(lease_id)
|
|
236
|
+
if record.state == TaskState.COMPLETED and record.accepted_attempt_id == lease.lease_id:
|
|
237
|
+
# The winner asking again (its 200 was lost to a timeout, and it
|
|
238
|
+
# retried). Answering False told the one machine that actually
|
|
239
|
+
# did the work that it had lost — audit §2 "retried complete".
|
|
240
|
+
# Idempotent means same answer, and no second ACCEPTED event:
|
|
241
|
+
# the ledger already records the commit that happened.
|
|
242
|
+
return True
|
|
181
243
|
if record.state == TaskState.COMPLETED or not self._is_live(record, lease, now):
|
|
182
244
|
self._emit(
|
|
183
245
|
EventType.TASK_COMMIT_REJECTED,
|
|
@@ -202,11 +264,50 @@ class LeaseManager:
|
|
|
202
264
|
)
|
|
203
265
|
return True
|
|
204
266
|
|
|
267
|
+
def note_progress(
|
|
268
|
+
self, job_id: str, task_id: str, now: datetime | None = None
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Record that the holder made *observable* progress on this task.
|
|
271
|
+
|
|
272
|
+
The other half of the C11 watchdog: heartbeats keep the deadline
|
|
273
|
+
alive, this keeps the silence budget alive. Callers are whatever the
|
|
274
|
+
workload reports through — a metric, a step counter, a partial
|
|
275
|
+
artifact write.
|
|
276
|
+
|
|
277
|
+
A silent no-op for an unknown task or one that is not LEASED: the
|
|
278
|
+
relay can always race an expiry, and a progress note arriving one
|
|
279
|
+
tick after the sweep took the lease away must not resurrect
|
|
280
|
+
anything, nor error at a reporter that did nothing wrong.
|
|
281
|
+
"""
|
|
282
|
+
record = self._store.get(job_id, task_id)
|
|
283
|
+
if record is None or record.state != TaskState.LEASED:
|
|
284
|
+
return
|
|
285
|
+
record.last_progress_at = now or _utcnow()
|
|
286
|
+
self._store.save(record)
|
|
287
|
+
|
|
205
288
|
def fail(self, lease_id: str, reason: str, now: datetime | None = None) -> None:
|
|
206
289
|
"""A worker reports its attempt failed. The task requeues while
|
|
207
|
-
attempts remain, otherwise it is FAILED for good.
|
|
290
|
+
attempts remain, otherwise it is FAILED for good.
|
|
291
|
+
|
|
292
|
+
A report against a *known but dead* lease is recorded and ignored,
|
|
293
|
+
not raised (audit §5.1): the attempt really did fail, and that is
|
|
294
|
+
evidence worth keeping, but the task has already moved on — spending
|
|
295
|
+
its failure budget or touching its state would punish whoever holds
|
|
296
|
+
it now. Only a lease that never existed is an error.
|
|
297
|
+
"""
|
|
208
298
|
now = now or _utcnow()
|
|
209
|
-
record, lease = self.
|
|
299
|
+
record, lease = self._find_lease(lease_id)
|
|
300
|
+
if not self._is_live(record, lease, now):
|
|
301
|
+
self._emit(
|
|
302
|
+
EventType.TASK_ATTEMPT_FAILED,
|
|
303
|
+
lease.job_id,
|
|
304
|
+
lease.task_id,
|
|
305
|
+
node_id=lease.node_id,
|
|
306
|
+
detail=f"(late report — lease no longer live) {reason}",
|
|
307
|
+
now=now,
|
|
308
|
+
)
|
|
309
|
+
return
|
|
310
|
+
record.failures_seen += 1
|
|
210
311
|
self._emit(
|
|
211
312
|
EventType.TASK_ATTEMPT_FAILED,
|
|
212
313
|
lease.job_id,
|
|
@@ -215,7 +316,7 @@ class LeaseManager:
|
|
|
215
316
|
detail=reason,
|
|
216
317
|
now=now,
|
|
217
318
|
)
|
|
218
|
-
self._release(record, now,
|
|
319
|
+
self._release(record, now, EventType.TASK_REQUEUED, budget="failure")
|
|
219
320
|
|
|
220
321
|
# -- coordinator side ---------------------------------------------------
|
|
221
322
|
|
|
@@ -230,6 +331,7 @@ class LeaseManager:
|
|
|
230
331
|
if lease is None or lease.deadline > now:
|
|
231
332
|
continue
|
|
232
333
|
expired += 1
|
|
334
|
+
record.expiries_seen += 1
|
|
233
335
|
self._emit(
|
|
234
336
|
EventType.LEASE_EXPIRED,
|
|
235
337
|
lease.job_id,
|
|
@@ -238,9 +340,37 @@ class LeaseManager:
|
|
|
238
340
|
detail=f"attempt {lease.attempt_number} heartbeat deadline passed",
|
|
239
341
|
now=now,
|
|
240
342
|
)
|
|
241
|
-
self._release(record, now,
|
|
343
|
+
self._release(record, now, EventType.TASK_REQUEUED, budget="expiry")
|
|
242
344
|
return expired
|
|
243
345
|
|
|
346
|
+
def force_expire(
|
|
347
|
+
self, job_id: str, task_id: str, reason: str, now: datetime | None = None
|
|
348
|
+
) -> bool:
|
|
349
|
+
"""Revoke a task's live lease now, by operator decision.
|
|
350
|
+
|
|
351
|
+
The manual lever behind the automatic ones (audit §5.1): when a
|
|
352
|
+
machine is known-stuck but its deadline is minutes away, waiting is
|
|
353
|
+
the only thing an operator could do. Returns False when the task is
|
|
354
|
+
not LEASED — there is nothing to take away, and re-running the same
|
|
355
|
+
command is then a no-op rather than an error.
|
|
356
|
+
"""
|
|
357
|
+
now = now or _utcnow()
|
|
358
|
+
record = self._require(job_id, task_id)
|
|
359
|
+
if record.state != TaskState.LEASED:
|
|
360
|
+
return False
|
|
361
|
+
lease = record.active_lease
|
|
362
|
+
self._emit(
|
|
363
|
+
EventType.LEASE_EXPIRED,
|
|
364
|
+
job_id,
|
|
365
|
+
task_id,
|
|
366
|
+
node_id=lease.node_id if lease is not None else None,
|
|
367
|
+
detail=f"force-expired: {reason}",
|
|
368
|
+
now=now,
|
|
369
|
+
)
|
|
370
|
+
record.expiries_seen += 1
|
|
371
|
+
self._release(record, now, EventType.TASK_REQUEUED, budget="expiry")
|
|
372
|
+
return True
|
|
373
|
+
|
|
244
374
|
def job_state(self, job_id: str) -> dict[str, int]:
|
|
245
375
|
"""Task counts by state — the number the dashboard shows."""
|
|
246
376
|
counts: dict[str, int] = {}
|
|
@@ -260,6 +390,20 @@ class LeaseManager:
|
|
|
260
390
|
return None
|
|
261
391
|
return lease
|
|
262
392
|
|
|
393
|
+
def lease_is_live(self, lease_id: str, now: datetime | None = None) -> bool:
|
|
394
|
+
"""Is this lease still the good one? A question, so it answers.
|
|
395
|
+
|
|
396
|
+
Unlike `_find_lease`, an unknown id is False rather than an error:
|
|
397
|
+
callers ask this to decide whether to keep going, and "I have never
|
|
398
|
+
heard of that lease" is a perfectly good "stop".
|
|
399
|
+
"""
|
|
400
|
+
now = now if now is not None else _utcnow()
|
|
401
|
+
try:
|
|
402
|
+
record, lease = self._find_lease(lease_id)
|
|
403
|
+
except LeaseError:
|
|
404
|
+
return False
|
|
405
|
+
return self._is_live(record, lease, now)
|
|
406
|
+
|
|
263
407
|
def live_leases_for_node(
|
|
264
408
|
self, node_id: str, now: datetime | None = None
|
|
265
409
|
) -> set[tuple[str, str]]:
|
|
@@ -294,15 +438,42 @@ class LeaseManager:
|
|
|
294
438
|
|
|
295
439
|
# -- internals ----------------------------------------------------------
|
|
296
440
|
|
|
297
|
-
def _release(
|
|
441
|
+
def _release(
|
|
442
|
+
self,
|
|
443
|
+
record: TaskRecord,
|
|
444
|
+
now: datetime,
|
|
445
|
+
requeue_event: EventType,
|
|
446
|
+
*,
|
|
447
|
+
budget: str,
|
|
448
|
+
) -> None:
|
|
449
|
+
"""Return a task to PENDING, or exhaust it — against the budget the
|
|
450
|
+
caller spent (audit C12).
|
|
451
|
+
|
|
452
|
+
`"failure"` and `"expiry"` are separate budgets on purpose: a
|
|
453
|
+
worker that keeps reporting real failures is different evidence
|
|
454
|
+
from a lease that keeps lapsing unclaimed (pure churn — no worker
|
|
455
|
+
ever ran it), so each is checked against its own counter
|
|
456
|
+
(`spec.max_attempts` / `spec.max_expiries`) and neither spends the
|
|
457
|
+
other's.
|
|
458
|
+
"""
|
|
298
459
|
record.active_lease = None
|
|
299
|
-
|
|
460
|
+
exhausted = (
|
|
461
|
+
record.failures_seen >= record.spec.max_attempts
|
|
462
|
+
if budget == "failure"
|
|
463
|
+
else record.expiries_seen >= record.spec.max_expiries
|
|
464
|
+
)
|
|
465
|
+
if exhausted:
|
|
300
466
|
record.state = TaskState.FAILED
|
|
467
|
+
detail = (
|
|
468
|
+
f"all {record.spec.max_attempts} attempts failed"
|
|
469
|
+
if budget == "failure"
|
|
470
|
+
else f"lease expired {record.expiries_seen} times without completing"
|
|
471
|
+
)
|
|
301
472
|
self._emit(
|
|
302
473
|
EventType.TASK_EXHAUSTED,
|
|
303
474
|
record.spec.job_id,
|
|
304
475
|
record.spec.task_id,
|
|
305
|
-
detail=
|
|
476
|
+
detail=detail,
|
|
306
477
|
now=now,
|
|
307
478
|
)
|
|
308
479
|
else:
|
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import json
|
|
18
18
|
import sqlite3
|
|
19
|
+
from datetime import datetime
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
|
|
21
22
|
from flashruntime.leases.store import TaskRecord
|
|
@@ -49,6 +50,7 @@ class SqliteLeaseStore:
|
|
|
49
50
|
self._conn = sqlite3.connect(str(path), check_same_thread=False)
|
|
50
51
|
self._migrate() # legacy single-PK table → composite
|
|
51
52
|
self._conn.executescript(_SCHEMA)
|
|
53
|
+
self._ensure_runtime_column() # additive: budget-split counters (audit C12)
|
|
52
54
|
self._cache: dict[tuple[str, str], TaskRecord] = {}
|
|
53
55
|
self._seq = 0
|
|
54
56
|
self._load()
|
|
@@ -79,15 +81,27 @@ class SqliteLeaseStore:
|
|
|
79
81
|
)
|
|
80
82
|
self._conn.commit()
|
|
81
83
|
|
|
84
|
+
def _ensure_runtime_column(self) -> None:
|
|
85
|
+
"""Additive migration (audit C12): one nullable JSON column carries
|
|
86
|
+
the budget-split counters (`failures_seen`, `expiries_seen`,
|
|
87
|
+
`last_progress_at`). A plain `ADD COLUMN` — unlike the composite-key
|
|
88
|
+
rebuild above, no rewrite is needed since every existing row simply
|
|
89
|
+
reads back NULL, and `_load` treats that as the pre-upgrade
|
|
90
|
+
zeros/None defaults rather than failing to load."""
|
|
91
|
+
cols = {row[1] for row in self._conn.execute("PRAGMA table_info(lease_tasks)").fetchall()}
|
|
92
|
+
if "runtime_json" not in cols:
|
|
93
|
+
self._conn.execute("ALTER TABLE lease_tasks ADD COLUMN runtime_json TEXT")
|
|
94
|
+
self._conn.commit()
|
|
95
|
+
|
|
82
96
|
# -- rehydration ---------------------------------------------------------
|
|
83
97
|
|
|
84
98
|
def _load(self) -> None:
|
|
85
99
|
rows = self._conn.execute(
|
|
86
100
|
"SELECT spec_json, state, attempts_used, active_lease_json,"
|
|
87
|
-
" accepted_attempt_id, lease_history_json, seq"
|
|
101
|
+
" accepted_attempt_id, lease_history_json, runtime_json, seq"
|
|
88
102
|
" FROM lease_tasks ORDER BY seq"
|
|
89
103
|
).fetchall()
|
|
90
|
-
for spec_json, state, attempts, lease_json, accepted, history_json, seq in rows:
|
|
104
|
+
for spec_json, state, attempts, lease_json, accepted, history_json, runtime_json, seq in rows:
|
|
91
105
|
record = TaskRecord(TaskSpec.model_validate_json(spec_json))
|
|
92
106
|
record.state = TaskState(state)
|
|
93
107
|
record.attempts_used = attempts
|
|
@@ -97,6 +111,15 @@ class SqliteLeaseStore:
|
|
|
97
111
|
lid: Lease.model_validate(raw)
|
|
98
112
|
for lid, raw in json.loads(history_json).items()
|
|
99
113
|
}
|
|
114
|
+
# `.get(...)` defaults: a row written before this column existed
|
|
115
|
+
# (or with it explicitly NULL) loads as zeros/None, not an error.
|
|
116
|
+
runtime = json.loads(runtime_json) if runtime_json else {}
|
|
117
|
+
record.failures_seen = runtime.get("failures_seen", 0)
|
|
118
|
+
record.expiries_seen = runtime.get("expiries_seen", 0)
|
|
119
|
+
last_progress_at = runtime.get("last_progress_at")
|
|
120
|
+
record.last_progress_at = (
|
|
121
|
+
datetime.fromisoformat(last_progress_at) if last_progress_at else None
|
|
122
|
+
)
|
|
100
123
|
self._cache[(record.spec.job_id, record.spec.task_id)] = record
|
|
101
124
|
self._seq = max(self._seq, seq or 0)
|
|
102
125
|
|
|
@@ -140,18 +163,28 @@ class SqliteLeaseStore:
|
|
|
140
163
|
history = json.dumps(
|
|
141
164
|
{lid: json.loads(lease.model_dump_json()) for lid, lease in record.lease_history.items()}
|
|
142
165
|
)
|
|
166
|
+
runtime = json.dumps(
|
|
167
|
+
{
|
|
168
|
+
"failures_seen": record.failures_seen,
|
|
169
|
+
"expiries_seen": record.expiries_seen,
|
|
170
|
+
"last_progress_at": (
|
|
171
|
+
record.last_progress_at.isoformat() if record.last_progress_at else None
|
|
172
|
+
),
|
|
173
|
+
}
|
|
174
|
+
)
|
|
143
175
|
self._conn.execute(
|
|
144
176
|
"INSERT INTO lease_tasks"
|
|
145
177
|
" (task_id, job_id, spec_json, state, attempts_used, active_lease_json,"
|
|
146
|
-
" accepted_attempt_id, lease_history_json, seq)"
|
|
147
|
-
" VALUES (?, ?, ?, ?, ?, ?, ?, ?,"
|
|
178
|
+
" accepted_attempt_id, lease_history_json, runtime_json, seq)"
|
|
179
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,"
|
|
148
180
|
" COALESCE(?, (SELECT seq FROM lease_tasks"
|
|
149
181
|
" WHERE job_id = ? AND task_id = ?)))"
|
|
150
182
|
" ON CONFLICT(job_id, task_id) DO UPDATE SET"
|
|
151
183
|
" state=excluded.state, attempts_used=excluded.attempts_used,"
|
|
152
184
|
" active_lease_json=excluded.active_lease_json,"
|
|
153
185
|
" accepted_attempt_id=excluded.accepted_attempt_id,"
|
|
154
|
-
" lease_history_json=excluded.lease_history_json"
|
|
186
|
+
" lease_history_json=excluded.lease_history_json,"
|
|
187
|
+
" runtime_json=excluded.runtime_json",
|
|
155
188
|
(
|
|
156
189
|
record.spec.task_id,
|
|
157
190
|
record.spec.job_id,
|
|
@@ -161,6 +194,7 @@ class SqliteLeaseStore:
|
|
|
161
194
|
record.active_lease.model_dump_json() if record.active_lease else None,
|
|
162
195
|
record.accepted_attempt_id,
|
|
163
196
|
history,
|
|
197
|
+
runtime,
|
|
164
198
|
seq,
|
|
165
199
|
record.spec.job_id,
|
|
166
200
|
record.spec.task_id,
|
|
@@ -11,6 +11,7 @@ manager, never in a store, so every backend behaves identically.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
from datetime import datetime
|
|
14
15
|
from typing import Protocol
|
|
15
16
|
|
|
16
17
|
from flashruntime.protocol.v1alpha1 import Lease, TaskSpec, TaskState
|
|
@@ -22,9 +23,28 @@ class TaskRecord:
|
|
|
22
23
|
`lease_history` keeps every lease ever issued for the task so a late
|
|
23
24
|
commit from a long-dead worker can be *identified and rejected* rather
|
|
24
25
|
than erroring as unknown — rejected duplicates are recovery evidence.
|
|
26
|
+
|
|
27
|
+
`failures_seen` and `expiries_seen` (audit C12) are separate budgets:
|
|
28
|
+
a worker that reports a failure spends `failures_seen` against
|
|
29
|
+
`spec.max_attempts`; a lease that lapses unclaimed spends
|
|
30
|
+
`expiries_seen` against `spec.max_expiries`. `attempts_used` still
|
|
31
|
+
numbers attempts (and drives `Lease.attempt_number`), but no longer by
|
|
32
|
+
itself decides anything terminal. `last_progress_at` is stamped on
|
|
33
|
+
every claim — the last time some worker showed up for this task at
|
|
34
|
+
all, regardless of how that attempt ended.
|
|
25
35
|
"""
|
|
26
36
|
|
|
27
|
-
__slots__ = (
|
|
37
|
+
__slots__ = (
|
|
38
|
+
"spec",
|
|
39
|
+
"state",
|
|
40
|
+
"attempts_used",
|
|
41
|
+
"active_lease",
|
|
42
|
+
"accepted_attempt_id",
|
|
43
|
+
"lease_history",
|
|
44
|
+
"failures_seen",
|
|
45
|
+
"expiries_seen",
|
|
46
|
+
"last_progress_at",
|
|
47
|
+
)
|
|
28
48
|
|
|
29
49
|
def __init__(self, spec: TaskSpec):
|
|
30
50
|
self.spec = spec
|
|
@@ -33,6 +53,9 @@ class TaskRecord:
|
|
|
33
53
|
self.active_lease: Lease | None = None
|
|
34
54
|
self.accepted_attempt_id: str | None = None
|
|
35
55
|
self.lease_history: dict[str, Lease] = {}
|
|
56
|
+
self.failures_seen = 0
|
|
57
|
+
self.expiries_seen = 0
|
|
58
|
+
self.last_progress_at: datetime | None = None
|
|
36
59
|
|
|
37
60
|
|
|
38
61
|
class LeaseStore(Protocol):
|
|
@@ -233,6 +233,9 @@ class EventType(str, Enum):
|
|
|
233
233
|
FAILURE_CLASSIFIED = "FAILURE_CLASSIFIED"
|
|
234
234
|
RECOVERY_ACTION_SELECTED = "RECOVERY_ACTION_SELECTED"
|
|
235
235
|
RECOVERY_FROZEN = "RECOVERY_FROZEN"
|
|
236
|
+
# Lease hardening (additive, August 2026)
|
|
237
|
+
TASK_CANCELLED = "TASK_CANCELLED"
|
|
238
|
+
LEASE_RENEWAL_REFUSED = "LEASE_RENEWAL_REFUSED"
|
|
236
239
|
|
|
237
240
|
|
|
238
241
|
class Event(BaseModel):
|
|
@@ -332,6 +335,17 @@ class NodeCapabilities(BaseModel):
|
|
|
332
335
|
#: this runtime cannot parse — never a guess. Placement reads the LENGTH
|
|
333
336
|
#: of this list and nothing else in v1.
|
|
334
337
|
gpus: list[GpuInfo] = Field(default_factory=list)
|
|
338
|
+
#: Bytes this host will lend to cached dataset shards. Advertised so the
|
|
339
|
+
#: control plane can refuse a job at SUBMIT time when no host in the pool
|
|
340
|
+
#: can hold a slice — twenty machines each downloading for forty minutes
|
|
341
|
+
#: and then refusing is the failure this exists to replace.
|
|
342
|
+
#:
|
|
343
|
+
#: Defaults to 0, and 0 means "send me no dataset work". Fail closed, the
|
|
344
|
+
#: same polarity as `can_install_dependencies`: an agent whose
|
|
345
|
+
#: registration predates this field has nowhere to put a shard and must
|
|
346
|
+
#: never be handed one. Bytes rather than GB to match `memory_bytes` —
|
|
347
|
+
#: one unit for every capacity on this model.
|
|
348
|
+
dataset_cache_bytes: int = 0
|
|
335
349
|
#: Team pools this node serves, as pool ids minted by the cloud control
|
|
336
350
|
#: plane. Stamped SERVER-SIDE by the cloud API's agent proxy from the
|
|
337
351
|
#: machine owner's memberships — an agent's self-reported value is
|
|
@@ -451,6 +465,8 @@ class TaskSpec(BaseModel):
|
|
|
451
465
|
commit_key: str
|
|
452
466
|
max_attempts: int = Field(default=3, ge=1)
|
|
453
467
|
lease_seconds: float = Field(default=60.0, gt=0)
|
|
468
|
+
max_expiries: int = Field(default=9, ge=1) # lease-expiry budget, separate from max_attempts
|
|
469
|
+
max_silent_seconds: float = Field(default=0.0, ge=0) # renewal watchdog window; 0 (default) disables — the coordinator arms this at task-expansion time for tasks that produce progress signals
|
|
454
470
|
|
|
455
471
|
@field_validator("lease_seconds")
|
|
456
472
|
@classmethod
|
|
@@ -481,6 +497,7 @@ class Lease(BaseModel):
|
|
|
481
497
|
node_id: str
|
|
482
498
|
attempt_number: int
|
|
483
499
|
deadline: datetime
|
|
500
|
+
lease_seconds: float = 0.0 # issued by a coordinator that predates this field if 0.0
|
|
484
501
|
payload: dict[str, Any] = Field(default_factory=dict)
|
|
485
502
|
|
|
486
503
|
|
|
@@ -66,6 +66,18 @@ class CommandRecipe(WorkloadRecipe):
|
|
|
66
66
|
)
|
|
67
67
|
elif not all(isinstance(d, str) for d in extra_deps):
|
|
68
68
|
problems.append("'extra_dependencies' must be a list of strings")
|
|
69
|
+
slices = params.get("dataset_slices")
|
|
70
|
+
if slices is not None:
|
|
71
|
+
if not isinstance(slices, list) or isinstance(slices, (str, bytes)):
|
|
72
|
+
problems.append(
|
|
73
|
+
"'dataset_slices' must be a list with one entry per task "
|
|
74
|
+
"(a list, not a single string)"
|
|
75
|
+
)
|
|
76
|
+
elif not all(isinstance(s, list) for s in slices):
|
|
77
|
+
problems.append(
|
|
78
|
+
"'dataset_slices' must be a list of per-task lists, one "
|
|
79
|
+
"list of dataset slices for each task"
|
|
80
|
+
)
|
|
69
81
|
problems.extend(self._unpack_problems(params))
|
|
70
82
|
problems.extend(partition_problems(params))
|
|
71
83
|
problems.extend(declaration_problems(params.get("validators")))
|
|
@@ -221,6 +233,22 @@ class CommandRecipe(WorkloadRecipe):
|
|
|
221
233
|
# dies on ModuleNotFoundError, and both ends' unit tests
|
|
222
234
|
# still pass.
|
|
223
235
|
payload["dependencies"] = list(p["dependencies"])
|
|
236
|
+
if p.get("python") is not None:
|
|
237
|
+
# The interpreter `dependencies` above was resolved against
|
|
238
|
+
# ("3.11", major.minor, CPython) — declared by the submitter
|
|
239
|
+
# in flashml.yaml or derived by the compiler from the curated
|
|
240
|
+
# image the submitter chose. A no-container host provisions
|
|
241
|
+
# exactly this interpreter for the job's venv instead of
|
|
242
|
+
# trusting whatever python it happens to run.
|
|
243
|
+
#
|
|
244
|
+
# This forward exists because its absence was observed live
|
|
245
|
+
# on 2026-08-13: an Alibaba FC sandbox on Python 3.13 pulled
|
|
246
|
+
# pins wheeled only to cp312, every environment build died in
|
|
247
|
+
# a source compile, and the scheduler re-fed the same node
|
|
248
|
+
# until a healthy task was EXHAUSTED. Same failure shape as
|
|
249
|
+
# `dependencies` above: drop this line and both ends' unit
|
|
250
|
+
# tests still pass while the fleet fragments by interpreter.
|
|
251
|
+
payload["python"] = p["python"]
|
|
224
252
|
if p.get("extra_dependencies"):
|
|
225
253
|
# The extras alone, named separately from `dependencies`
|
|
226
254
|
# above: the eighth placement gate keys on THIS field, not
|
|
@@ -239,6 +267,31 @@ class CommandRecipe(WorkloadRecipe):
|
|
|
239
267
|
# hop have tests that pass while it is broken, because each
|
|
240
268
|
# constructs the payload directly.
|
|
241
269
|
payload["extra_dependencies"] = list(p["extra_dependencies"])
|
|
270
|
+
if p.get("dataset_slices") is not None:
|
|
271
|
+
# The ONLY per-task payload key in this method. Every other
|
|
272
|
+
# forward above copies one value to every task; each task
|
|
273
|
+
# fetches a DIFFERENT slice of the manifest, so this one is
|
|
274
|
+
# indexed by task position exactly as `task_params` is.
|
|
275
|
+
#
|
|
276
|
+
# Computed by the flashml-cloud compiler and opaque here:
|
|
277
|
+
# the byte-weighted mapping lives beside `fleet_shape` in
|
|
278
|
+
# that repo's `elastic.py`, and a second implementation here
|
|
279
|
+
# is the drift that module's docstring warns about.
|
|
280
|
+
#
|
|
281
|
+
# Same failure shape as `local_inputs` and `dependencies`:
|
|
282
|
+
# drop this line and every host fetches nothing, the workload
|
|
283
|
+
# dies on a missing path, and both ends' unit tests still
|
|
284
|
+
# pass because each constructs the payload directly.
|
|
285
|
+
slices = p["dataset_slices"]
|
|
286
|
+
if i >= len(slices):
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f"task {i}: 'dataset_slices' has {len(slices)} "
|
|
289
|
+
f"entr{'y' if len(slices) == 1 else 'ies'} but this "
|
|
290
|
+
f"job expanded to more tasks — every task needs a "
|
|
291
|
+
f"slice, even an empty one"
|
|
292
|
+
)
|
|
293
|
+
# `list(...)` so a payload never aliases the caller's spec.
|
|
294
|
+
payload["datasets"] = list(slices[i])
|
|
242
295
|
gpus = spec.spec.resources.gpuPerTask
|
|
243
296
|
if gpus:
|
|
244
297
|
# The GPU requirement is the ONLY payload key sourced from
|
|
@@ -386,6 +386,56 @@ class IsolationAwarePlacement(PlacementPolicy):
|
|
|
386
386
|
waiver relaxes the sandbox tier and nothing else. No waiver makes a
|
|
387
387
|
read-only container with no network able to run `pip install`.
|
|
388
388
|
|
|
389
|
+
A ninth gate applies to tasks whose payload carries `datasets`: the sum
|
|
390
|
+
of every slice entry's `size` must fit inside the claiming node's
|
|
391
|
+
`capabilities.dataset_cache_bytes`. This is CAPACITY, not capability —
|
|
392
|
+
every tier can fetch, because the AGENT fetches before the sandbox
|
|
393
|
+
closes, so unlike `extra_dependencies` there is no tier that physically
|
|
394
|
+
cannot. The only question is whether the bytes fit.
|
|
395
|
+
|
|
396
|
+
Not to be confused with the fourth gate. `local_inputs`/`local_datasets`
|
|
397
|
+
asks whether a host already HOLDS named data it will never upload; this
|
|
398
|
+
one asks whether a host has ROOM for data it is about to fetch from a
|
|
399
|
+
public origin. Different payload key, different capability, different
|
|
400
|
+
question.
|
|
401
|
+
|
|
402
|
+
It takes the argv/local-data/gpu/exclude_nodes/pool/dependencies
|
|
403
|
+
polarity — **fail closed** — and every leg of it:
|
|
404
|
+
|
|
405
|
+
- A node advertising nothing (an agent whose registration predates the
|
|
406
|
+
field) is refused, and so is one advertising `0`, which is the model
|
|
407
|
+
default and means "send me no dataset work". A shard with nowhere to
|
|
408
|
+
go is a task that dies after a forty-minute download rather than one
|
|
409
|
+
that never started, and twenty machines doing that at once is the
|
|
410
|
+
failure the advertisement exists to prevent.
|
|
411
|
+
- The advertisement must be a genuine `int`. `bool` is excluded
|
|
412
|
+
explicitly for the reason the `gpus` gate excludes it: it is an `int`
|
|
413
|
+
subclass, so a `dataset_cache_bytes: true` typo would read as the
|
|
414
|
+
capacity 1.
|
|
415
|
+
- `capabilities` itself may be absent or type-confused; that is read as
|
|
416
|
+
advertising nothing rather than allowed to raise. `isinstance`, never
|
|
417
|
+
`(node.get("capabilities") or {})` — a string `capabilities` value has
|
|
418
|
+
no `.get` and would crash the predicate inside the claim path, taking
|
|
419
|
+
the whole claim down rather than one task.
|
|
420
|
+
- A non-dict slice, a non-dict entry, or an entry whose `size` is not a
|
|
421
|
+
non-negative `int` makes the task ineligible everywhere. The manifest
|
|
422
|
+
is submitter-controlled, so an unreadable size must NOT contribute 0 to
|
|
423
|
+
the total: that is precisely how a hostile slice would buy itself an
|
|
424
|
+
unbounded fetch on a node that advertised a modest cache.
|
|
425
|
+
- The requirement is the sum over every entry of every slice. Checking
|
|
426
|
+
per-dataset, or against the largest entry, would place a task that then
|
|
427
|
+
fills a volunteer's disk halfway through the fetch.
|
|
428
|
+
- Empty and absent both mean "declares no data": `if
|
|
429
|
+
task.payload.get("datasets")` treats `None` and `[]` alike, so every
|
|
430
|
+
job deployed today is completely unaffected. A slice that is PRESENT
|
|
431
|
+
but happens to carry no entries is still dataset work and still needs
|
|
432
|
+
a readable advertisement — the fail-closed leg is the advertisement,
|
|
433
|
+
not the total.
|
|
434
|
+
|
|
435
|
+
`allowFallback` does not waive this gate either. It waives the sandbox
|
|
436
|
+
tier and nothing else; a submitter's isolation posture has nothing to say
|
|
437
|
+
about how much disk a stranger's machine offered.
|
|
438
|
+
|
|
389
439
|
Everything genuinely standard keeps the fail-open placement default."""
|
|
390
440
|
|
|
391
441
|
def eligible(self, task: TaskSpec, node: NodeView) -> bool:
|
|
@@ -516,6 +566,48 @@ class IsolationAwarePlacement(PlacementPolicy):
|
|
|
516
566
|
if task.payload.get("extra_dependencies"):
|
|
517
567
|
if node.get("can_install_dependencies") is not True:
|
|
518
568
|
return False
|
|
569
|
+
# Capacity, not capability, and the only gate in this class that is.
|
|
570
|
+
# Every tier's AGENT has network and a writable disk before the
|
|
571
|
+
# sandbox closes, so unlike `extra_dependencies` there is no tier
|
|
572
|
+
# that physically cannot fetch — the only question is whether the
|
|
573
|
+
# bytes fit. Distinct from the `local_inputs` gate above: that one
|
|
574
|
+
# asks whether a host already HOLDS named data, this one whether it
|
|
575
|
+
# has ROOM for data it is about to fetch.
|
|
576
|
+
#
|
|
577
|
+
# Fail-closed on an absent or type-confused advertisement, and
|
|
578
|
+
# checked before the allowFallback waiver below for the same reason
|
|
579
|
+
# as every gate above: the waiver relaxes the sandbox tier and has
|
|
580
|
+
# nothing to say about how much disk a stranger's machine offered.
|
|
581
|
+
if task.payload.get("datasets"):
|
|
582
|
+
needed = 0
|
|
583
|
+
for dataset in task.payload["datasets"]:
|
|
584
|
+
if not isinstance(dataset, dict):
|
|
585
|
+
return False # type-confused slice ⇒ fail closed, no crash
|
|
586
|
+
for entry in dataset.get("entries") or []:
|
|
587
|
+
size = entry.get("size") if isinstance(entry, dict) else None
|
|
588
|
+
# A non-int size is a malformed slice, not a free one:
|
|
589
|
+
# letting it contribute 0 is how a hostile manifest buys
|
|
590
|
+
# an unbounded fetch on a modest cache. `bool` is an
|
|
591
|
+
# `int` subclass, so `size: true` is excluded explicitly.
|
|
592
|
+
if (
|
|
593
|
+
not isinstance(size, int)
|
|
594
|
+
or isinstance(size, bool)
|
|
595
|
+
or size < 0
|
|
596
|
+
):
|
|
597
|
+
return False
|
|
598
|
+
needed += size
|
|
599
|
+
capabilities = node.get("capabilities")
|
|
600
|
+
# isinstance, not `or {}` — a string capabilities value has no
|
|
601
|
+
# `.get` and must fail closed rather than crash the predicate.
|
|
602
|
+
advertised = (
|
|
603
|
+
capabilities.get("dataset_cache_bytes")
|
|
604
|
+
if isinstance(capabilities, dict)
|
|
605
|
+
else None
|
|
606
|
+
)
|
|
607
|
+
if not isinstance(advertised, int) or isinstance(advertised, bool):
|
|
608
|
+
return False # absent/type-confused ⇒ advertises nothing
|
|
609
|
+
if needed > advertised:
|
|
610
|
+
return False
|
|
519
611
|
isolation = task.payload.get("isolation")
|
|
520
612
|
if isolation is None:
|
|
521
613
|
return True # no isolation payload ⇒ standard, runs anywhere
|