flashruntime 0.6.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.6.0/flashruntime.egg-info → flashruntime-0.6.1}/PKG-INFO +1 -1
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/leases/manager.py +181 -10
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/leases/sqlite_store.py +39 -5
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/leases/store.py +24 -1
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/protocol/v1alpha1.py +6 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recipes/command.py +16 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/checkpoints.py +49 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/modea.py +139 -4
- {flashruntime-0.6.0 → flashruntime-0.6.1/flashruntime.egg-info}/PKG-INFO +1 -1
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime.egg-info/SOURCES.txt +1 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/pyproject.toml +9 -1
- flashruntime-0.6.1/tests/test_leases.py +425 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_leases_sqlite.py +77 -3
- flashruntime-0.6.1/tests/test_protocol_lease_fields.py +48 -0
- {flashruntime-0.6.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.6.0 → flashruntime-0.6.1}/tests/test_service_delegation.py +39 -12
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_modea.py +120 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_write_scope.py +16 -3
- flashruntime-0.6.0/tests/test_leases.py +0 -204
- flashruntime-0.6.0/tests/test_service_checkpoints.py +0 -122
- {flashruntime-0.6.0 → flashruntime-0.6.1}/LICENSE +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/README.md +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/chunks.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/fedavg_driver.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/fedavg_weights.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/fedavg_worker.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/kmeans_driver.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/kmeans_shard.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/sgd_trainer.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/sharded_kmeans.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/sklearn_trial.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/artifacts/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/artifacts/store.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/backends/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/backends/base.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/backends/kuberay.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/checkpoint/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/checkpoint/catalog.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/checkpoint/local.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/checkpoint/store.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/envelope.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/images/TAG +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/images/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/images/python-slim.txt +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/images/pytorch-cpu.txt +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/images/pytorch-cuda.txt +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/images/sklearn.txt +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/integrations/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/integrations/huggingface.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/integrations/pytorch.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/integrations/sklearn.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/launchers/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/launchers/local.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/leases/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/monitor/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/monitor/sampler.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/candidates.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/catalog.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/comm.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/explain.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/memory.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/resolve.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/selector.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/timecost.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/profiling/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/protocol/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/protocol/plan_v1alpha1.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/providers/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/py.typed +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recipes/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recipes/partition.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recovery/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recovery/policy.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recovery/signals.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recovery/taxonomy.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/reduce.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/retention.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/scheduler/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/sdk.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/app.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/auth.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/cli.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/dashboard.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/ledger.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/strategies/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/strategies/command.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/torch/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/validators.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/benchmarks.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/get-started.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/index.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/cli.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/search-index.json +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/flowmap.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/page.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/server.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/state.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/workloads/__init__.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/workloads/command.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime.egg-info/dependency_links.txt +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime.egg-info/entry_points.txt +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime.egg-info/requires.txt +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime.egg-info/top_level.txt +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/setup.cfg +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_auto_recovery.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_benchmarks.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_checkpoint.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_checkpoint_local.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_chunks.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_cli_submit.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_cost_benchmark.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_declared_validators.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_documentation.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_envelope.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_examples_e2e.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_fedavg_convergence.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_fedavg_driver.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_fedavg_weights.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_fedavg_worker.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_gpu_e2e.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_headline.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_image_manifest_lookup.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_integrations.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_interfaces.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_job_state_partial.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_kmeans_leases.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_kmeans_workload.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_kuberay_backend.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_launcher_local.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_lease_seconds_bound.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_leases_scope.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_monitor_sampler.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_datasets.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_dependencies.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_exclusion.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_gpu.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_local_data.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_pool.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_reliability.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_trusted_argv.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_planner.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_protocol_datasets.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_protocol_evidence.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_protocol_gpu.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_protocol_local_datasets.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_protocol_pools.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_protocol_v1alpha1.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recipe_datasets.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recipe_gpu.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recipe_local_inputs.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recipe_partition.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recipe_pool.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recovery.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_reduce.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_retention.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_run_json.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_scheduler_isolation.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_sdk_submit.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_artifact_delete.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_auth.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_auth_startup.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_command_recipe.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_fedavg.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_job_result.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_reliability.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_validate_output.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_sgd_trainer.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_strategy_command.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_submit_envelope.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_submit_retention.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_torch_helper.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_viewer_flowmap.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_viewer_page.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_viewer_server.py +0 -0
- {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_viewer_state.py +0 -0
- {flashruntime-0.6.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.6.
|
|
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):
|
|
@@ -462,6 +465,8 @@ class TaskSpec(BaseModel):
|
|
|
462
465
|
commit_key: str
|
|
463
466
|
max_attempts: int = Field(default=3, ge=1)
|
|
464
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
|
|
465
470
|
|
|
466
471
|
@field_validator("lease_seconds")
|
|
467
472
|
@classmethod
|
|
@@ -492,6 +497,7 @@ class Lease(BaseModel):
|
|
|
492
497
|
node_id: str
|
|
493
498
|
attempt_number: int
|
|
494
499
|
deadline: datetime
|
|
500
|
+
lease_seconds: float = 0.0 # issued by a coordinator that predates this field if 0.0
|
|
495
501
|
payload: dict[str, Any] = Field(default_factory=dict)
|
|
496
502
|
|
|
497
503
|
|
|
@@ -233,6 +233,22 @@ class CommandRecipe(WorkloadRecipe):
|
|
|
233
233
|
# dies on ModuleNotFoundError, and both ends' unit tests
|
|
234
234
|
# still pass.
|
|
235
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"]
|
|
236
252
|
if p.get("extra_dependencies"):
|
|
237
253
|
# The extras alone, named separately from `dependencies`
|
|
238
254
|
# above: the eighth placement gate keys on THIS field, not
|
|
@@ -26,6 +26,33 @@ def _scope(job_id: str, task_id: str) -> str:
|
|
|
26
26
|
return f"{job_id}::{task_id}"
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
def _require_live_lease_on_this_task(manager, attempt_id: str, job_id: str, task_id: str) -> None:
|
|
30
|
+
"""410 unless `attempt_id` is a lease that is both LIVE and *this
|
|
31
|
+
path's* (job_id, task_id) — not merely live somewhere.
|
|
32
|
+
|
|
33
|
+
`manager.lease_is_live(attempt_id)` alone answers a global question:
|
|
34
|
+
"is this lease id still the good one for whatever task it was issued
|
|
35
|
+
against?" That is true for any node currently holding any lease at
|
|
36
|
+
all, so checking liveness alone let a caller present a live lease from
|
|
37
|
+
task A against task B's checkpoint path — writing into a foreign
|
|
38
|
+
task's checkpoint lineage and resetting a foreign task's watchdog
|
|
39
|
+
baseline via `note_progress`. Binding the lease to the path pair
|
|
40
|
+
closes that: the presented attempt must be *this* task's own current
|
|
41
|
+
attempt, not merely proof that the caller holds work somewhere.
|
|
42
|
+
"""
|
|
43
|
+
lease = manager.lease_info(attempt_id)
|
|
44
|
+
if (
|
|
45
|
+
lease is None
|
|
46
|
+
or lease.job_id != job_id
|
|
47
|
+
or lease.task_id != task_id
|
|
48
|
+
or not manager.lease_is_live(attempt_id)
|
|
49
|
+
):
|
|
50
|
+
raise HTTPException(
|
|
51
|
+
status_code=410,
|
|
52
|
+
detail="lease is no longer live — checkpoint write refused",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
29
56
|
class RegisterPartRequest(BaseModel):
|
|
30
57
|
attempt_id: str
|
|
31
58
|
step: int = Field(ge=0)
|
|
@@ -48,12 +75,29 @@ def build_router(catalog: CheckpointCatalog, state, manager) -> APIRouter:
|
|
|
48
75
|
@router.post("/parts")
|
|
49
76
|
async def register_part(job_id: str, task_id: str, req: RegisterPartRequest, request: Request):
|
|
50
77
|
authorize_task_write(state, manager, request, job_id, task_id)
|
|
78
|
+
# Fencing (audit C13/§5.4): `attempt_id` must name the lease that is
|
|
79
|
+
# STILL the live one, AND still this task's own current attempt —
|
|
80
|
+
# not a live lease borrowed from somewhere else. A zombie whose
|
|
81
|
+
# lease already expired — the heartbeat thread survived past the
|
|
82
|
+
# sweep, or the watchdog revoked it — must not be able to keep
|
|
83
|
+
# writing checkpoint bytes for a task another node now holds, and
|
|
84
|
+
# a caller holding a DIFFERENT live task must not be able to write
|
|
85
|
+
# into or reset the watchdog for THIS one. 410, same as a refused
|
|
86
|
+
# heartbeat: the caller must stop.
|
|
87
|
+
_require_live_lease_on_this_task(manager, req.attempt_id, job_id, task_id)
|
|
51
88
|
catalog.register_part(_scope(job_id, task_id), req.attempt_id, req.step, req.part)
|
|
89
|
+
# Progress signal for the watchdog (audit C11), only now: after the
|
|
90
|
+
# fencing check passed and the write actually landed. Called with no
|
|
91
|
+
# `now` so the server clock stamps it — a client-supplied timestamp
|
|
92
|
+
# would let a stalled worker manufacture its own liveness.
|
|
93
|
+
manager.note_progress(job_id, task_id)
|
|
52
94
|
return {"status": "registered", "key": req.part.key}
|
|
53
95
|
|
|
54
96
|
@router.post("/commit")
|
|
55
97
|
async def commit(job_id: str, task_id: str, req: CommitRequest, request: Request):
|
|
56
98
|
authorize_task_write(state, manager, request, job_id, task_id)
|
|
99
|
+
# Same fencing as register_part above — see that comment.
|
|
100
|
+
_require_live_lease_on_this_task(manager, req.attempt_id, job_id, task_id)
|
|
57
101
|
try:
|
|
58
102
|
manifest = catalog.commit(
|
|
59
103
|
job_id=_scope(job_id, task_id),
|
|
@@ -67,7 +111,12 @@ def build_router(catalog: CheckpointCatalog, state, manager) -> APIRouter:
|
|
|
67
111
|
)
|
|
68
112
|
except CheckpointError as exc:
|
|
69
113
|
# 409: the manifest was refused — parts missing or hashes wrong.
|
|
114
|
+
# No progress note: a rejected commit proved nothing about the
|
|
115
|
+
# work, so it must not reset the silence clock either.
|
|
70
116
|
raise HTTPException(status_code=409, detail=str(exc))
|
|
117
|
+
# Only after the catalog accepted the manifest — a rejected commit
|
|
118
|
+
# above never reaches this line.
|
|
119
|
+
manager.note_progress(job_id, task_id)
|
|
71
120
|
return manifest
|
|
72
121
|
|
|
73
122
|
@router.get("/latest")
|