flashnode 0.3.5__tar.gz → 0.4.0__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.
- {flashnode-0.3.5 → flashnode-0.4.0}/PKG-INFO +2 -2
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/agent/cli.py +137 -0
- flashnode-0.4.0/flashnode/executor/datasets.py +457 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/loop.py +31 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/inventory/capabilities.py +27 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode.egg-info/PKG-INFO +2 -2
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode.egg-info/SOURCES.txt +5 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode.egg-info/requires.txt +1 -1
- {flashnode-0.3.5 → flashnode-0.4.0}/pyproject.toml +22 -2
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_capabilities.py +59 -0
- flashnode-0.4.0/tests/test_cli_data.py +150 -0
- flashnode-0.4.0/tests/test_datasets.py +155 -0
- flashnode-0.4.0/tests/test_datasets_fetch.py +240 -0
- flashnode-0.4.0/tests/test_loop_datasets.py +264 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/LICENSE +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/README.md +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/__init__.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/agent/__init__.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/agent/daemon.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/agent/kube.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/artifacts/__init__.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/benchmark/__init__.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/config/__init__.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/config/local_data.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/doctor.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/__init__.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/archives.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/argv_runner.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/client.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/docker_runner.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/environments.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/evidence.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/hardening.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/health.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/images.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/runner.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/task_logs.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/trusted_runner.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/identity/__init__.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/identity/credentials.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/identity/enrol.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/identity/store.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/inventory/__init__.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/inventory/gpu.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/status.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/telemetry/__init__.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode.egg-info/dependency_links.txt +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode.egg-info/entry_points.txt +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/flashnode.egg-info/top_level.txt +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/setup.cfg +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_agent.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_allowlist_drift.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_archives.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_argv_runner.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_checkpoint_relay.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_cli_env.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_cli_join.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_cli_trusted.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_credentials.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_docker_runner.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_doctor.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_enrol.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_environments.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_evidence.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_executor.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_gpu_probe.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_hardening.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_hardening_platform.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_identity.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_images_allowlist.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_interfaces.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_local_data.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_loop_counters.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_status.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_task_logs.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_tier_health.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_trusted_runner.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_trusted_tier_endtoend.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_work_gate.py +0 -0
- {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_work_status.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flashnode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Open host agent for the FlashML fragmented-compute network: join, benchmark, execute sandboxed ML tasks, earn contribution credits.
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://github.com/Zolli-Labs/flashnode
|
|
7
7
|
Requires-Python: >=3.10
|
|
8
8
|
Description-Content-Type: text/markdown
|
|
9
9
|
License-File: LICENSE
|
|
10
|
-
Requires-Dist: flashruntime<0.
|
|
10
|
+
Requires-Dist: flashruntime<0.7,>=0.6
|
|
11
11
|
Requires-Dist: psutil>=5.9
|
|
12
12
|
Requires-Dist: websockets>=12
|
|
13
13
|
Requires-Dist: cryptography>=42
|
|
@@ -29,6 +29,8 @@ commands:
|
|
|
29
29
|
doctor check this machine can run tasks (docker engine, images, mounts)
|
|
30
30
|
env see and reclaim disk held by cached dependency environments
|
|
31
31
|
(list | purge — purge never touches node-id or credentials.json)
|
|
32
|
+
data see and reclaim disk held by cached dataset shards
|
|
33
|
+
(list | purge — purge never touches node-id or credentials.json)
|
|
32
34
|
join connect this machine to a FlashML control plane (not yet implemented)
|
|
33
35
|
status show node identity, capabilities, and active leases (not yet implemented)
|
|
34
36
|
leave drain and disconnect (not yet implemented)
|
|
@@ -624,6 +626,139 @@ def _env(args: list[str]) -> int:
|
|
|
624
626
|
return 0
|
|
625
627
|
|
|
626
628
|
|
|
629
|
+
def _data(args: list[str]) -> int:
|
|
630
|
+
"""`flashnode data list|purge` — the disk cached dataset shards hold.
|
|
631
|
+
|
|
632
|
+
The sibling of `_env`, with the same contract: `purge` deletes only what
|
|
633
|
+
`DatasetCache.entries()` reports — the contents of
|
|
634
|
+
`$FLASHNODE_STATE_DIR/data/` — and never the state directory itself, so
|
|
635
|
+
`node-id` and `credentials.json` survive. That is the entire difference
|
|
636
|
+
between this command and un-enrolling the machine.
|
|
637
|
+
|
|
638
|
+
Its own command rather than a flag on `env` because the two caches hold
|
|
639
|
+
different orders of magnitude for different reasons, and a host owner
|
|
640
|
+
who wants to keep 8 GB of virtualenvs while giving back 200 GB of a
|
|
641
|
+
stranger's shards must be able to say exactly that.
|
|
642
|
+
"""
|
|
643
|
+
import argparse
|
|
644
|
+
import shutil
|
|
645
|
+
from datetime import datetime
|
|
646
|
+
|
|
647
|
+
from flashnode.executor.datasets import DatasetCache
|
|
648
|
+
|
|
649
|
+
parser = argparse.ArgumentParser(prog="flashnode data")
|
|
650
|
+
sub = parser.add_subparsers(dest="subcommand", required=True)
|
|
651
|
+
sub.add_parser("list", help="show cached shards, their size, and last use")
|
|
652
|
+
sub.add_parser(
|
|
653
|
+
"purge",
|
|
654
|
+
help="delete all cached dataset shards (identity and credentials "
|
|
655
|
+
"survive; stop `flashnode work` first — this can break a task "
|
|
656
|
+
"in flight)",
|
|
657
|
+
)
|
|
658
|
+
opts = parser.parse_args(args)
|
|
659
|
+
|
|
660
|
+
cache = DatasetCache()
|
|
661
|
+
entries = cache.entries() # most recently used first
|
|
662
|
+
|
|
663
|
+
# Scratch directories from fetches that were KILLED — SIGKILL, the OOM
|
|
664
|
+
# killer, a power cut. `entries()` skips dotted names, so without this
|
|
665
|
+
# the two commands whose whole job is to account for disk would be the
|
|
666
|
+
# two that could not see a half-downloaded multi-GB shard.
|
|
667
|
+
stale = cache.stale_bytes()
|
|
668
|
+
|
|
669
|
+
if opts.subcommand == "list":
|
|
670
|
+
if not entries and not stale:
|
|
671
|
+
print(
|
|
672
|
+
"flashnode data: no dataset shards cached — nothing is using "
|
|
673
|
+
"disk yet."
|
|
674
|
+
)
|
|
675
|
+
return 0
|
|
676
|
+
for entry in entries:
|
|
677
|
+
when = datetime.fromtimestamp(entry.last_used).strftime("%Y-%m-%d %H:%M")
|
|
678
|
+
# Truncated: the key is a sha256, and 64 hex characters per line
|
|
679
|
+
# buries the two columns the owner is actually reading.
|
|
680
|
+
print(
|
|
681
|
+
f" {entry.key[:16]}… {_human_bytes(entry.size_bytes):>8} "
|
|
682
|
+
f"last used {when}"
|
|
683
|
+
)
|
|
684
|
+
total = sum(e.size_bytes for e in entries) + stale
|
|
685
|
+
if stale:
|
|
686
|
+
print(
|
|
687
|
+
f" leftover fetch directories {_human_bytes(stale):>8} "
|
|
688
|
+
"from interrupted fetches"
|
|
689
|
+
)
|
|
690
|
+
print(
|
|
691
|
+
f"total: {_human_bytes(total)} across {len(entries)} shard(s) "
|
|
692
|
+
f"at {cache.root}"
|
|
693
|
+
)
|
|
694
|
+
if stale:
|
|
695
|
+
print(
|
|
696
|
+
" the leftover fetch directories are swept by the next "
|
|
697
|
+
"fetch, or now by `flashnode data purge`"
|
|
698
|
+
)
|
|
699
|
+
return 0
|
|
700
|
+
|
|
701
|
+
# purge
|
|
702
|
+
#
|
|
703
|
+
# This runs in a SEPARATE process from the agent, so it sidesteps the
|
|
704
|
+
# `_evict(protect=…)` that makes in-process eviction safe and can delete
|
|
705
|
+
# the shard a running task is reading through its hard link. Refusing
|
|
706
|
+
# would beat warning, but nothing in the state directory records a
|
|
707
|
+
# running agent — no pidfile, no lock — and a guessed signal would refuse
|
|
708
|
+
# on a machine with no agent at all. So: say it, plainly, every time, and
|
|
709
|
+
# before the deletion rather than after it.
|
|
710
|
+
print(
|
|
711
|
+
"flashnode data purge: warning — if `flashnode work` is running on "
|
|
712
|
+
"this machine, this can delete a shard a task is reading and fail "
|
|
713
|
+
"that task. This command cannot tell whether an agent is running. "
|
|
714
|
+
"Stop the agent first if you are not sure."
|
|
715
|
+
)
|
|
716
|
+
# Counted AFTER each removal, never before — the `_env` bug, which must
|
|
717
|
+
# not be reintroduced here. Totalling up front and deleting with
|
|
718
|
+
# `ignore_errors=True` prints the full figure as reclaimed when a
|
|
719
|
+
# permission error left the bytes on disk, on the one command whose
|
|
720
|
+
# entire value is that number and which a volunteer has no other way to
|
|
721
|
+
# check.
|
|
722
|
+
freed = 0
|
|
723
|
+
removed = 0
|
|
724
|
+
survivors = []
|
|
725
|
+
for entry in entries:
|
|
726
|
+
shutil.rmtree(entry.path, ignore_errors=True)
|
|
727
|
+
if entry.path.exists():
|
|
728
|
+
survivors.append(entry.path)
|
|
729
|
+
else:
|
|
730
|
+
freed += entry.size_bytes
|
|
731
|
+
removed += 1
|
|
732
|
+
stale_freed = cache.sweep_stale() # counts only what actually went
|
|
733
|
+
freed += stale_freed
|
|
734
|
+
survivors.extend(cache.stale_dirs())
|
|
735
|
+
leftovers = (
|
|
736
|
+
f" (including {_human_bytes(stale_freed)} of leftover fetch "
|
|
737
|
+
"directories from interrupted fetches)"
|
|
738
|
+
if stale_freed
|
|
739
|
+
else ""
|
|
740
|
+
)
|
|
741
|
+
print(
|
|
742
|
+
f"flashnode data purge: freed {_human_bytes(freed)} across "
|
|
743
|
+
f"{removed} shard(s){leftovers}. node-id and credentials.json were "
|
|
744
|
+
"not touched."
|
|
745
|
+
)
|
|
746
|
+
if survivors:
|
|
747
|
+
noun = "directory" if len(survivors) == 1 else "directories"
|
|
748
|
+
print(
|
|
749
|
+
f"flashnode data purge: could not remove {len(survivors)} {noun} "
|
|
750
|
+
"— the disk it holds was NOT reclaimed:"
|
|
751
|
+
)
|
|
752
|
+
for path in survivors:
|
|
753
|
+
print(f" {path}")
|
|
754
|
+
print(
|
|
755
|
+
" check the permissions on those paths, or whether another "
|
|
756
|
+
"process is holding them open, and run purge again."
|
|
757
|
+
)
|
|
758
|
+
return 1
|
|
759
|
+
return 0
|
|
760
|
+
|
|
761
|
+
|
|
627
762
|
def main(argv: list[str] | None = None) -> int:
|
|
628
763
|
args = sys.argv[1:] if argv is None else argv
|
|
629
764
|
if args and args[0] == "agent":
|
|
@@ -642,6 +777,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
642
777
|
return _logout(args[1:])
|
|
643
778
|
if args and args[0] == "env":
|
|
644
779
|
return _env(args[1:])
|
|
780
|
+
if args and args[0] == "data":
|
|
781
|
+
return _data(args[1:])
|
|
645
782
|
print(USAGE.format(version=__version__), end="")
|
|
646
783
|
if args and args[0] in {"join", "status", "leave"}:
|
|
647
784
|
print(f"\nerror: '{args[0]}' is not implemented yet in this scaffold.", file=sys.stderr)
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Cached dataset shards, one entry per shard checksum.
|
|
2
|
+
|
|
3
|
+
A task runs with `--network none` and cannot fetch its own data. The agent
|
|
4
|
+
fetches instead, before the sandbox closes, and this module is where the
|
|
5
|
+
bytes live between tasks.
|
|
6
|
+
|
|
7
|
+
Modelled on `environments.py`, and for the same reason: **disk is somebody
|
|
8
|
+
else's.** Bounded (FLASHNODE_DATA_BUDGET_GB, default 16), evicted
|
|
9
|
+
least-recently-USED, and a fetch is refused before it starts unless the
|
|
10
|
+
filesystem still has 1 GB free.
|
|
11
|
+
|
|
12
|
+
Two deliberate differences from EnvironmentCache:
|
|
13
|
+
|
|
14
|
+
* **The key is the shard's own checksum**, not a hash of a request. Two
|
|
15
|
+
jobs training on the same public dataset — or two revisions of one
|
|
16
|
+
dataset sharing an unchanged shard — hit the same entry for free.
|
|
17
|
+
* **Size is a written marker.** EnvironmentCache measures with a tree walk
|
|
18
|
+
on every `entries()` call, which `_evict` also calls. That is fine for a
|
|
19
|
+
handful of venvs and hopeless for a dataset of a million files.
|
|
20
|
+
|
|
21
|
+
A separate budget from the venv cache on purpose: a host owner should be
|
|
22
|
+
able to say yes to 8 GB of virtualenvs and no to 200 GB of a stranger's
|
|
23
|
+
images. Different orders of magnitude, different consent.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import http.client
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import shutil
|
|
33
|
+
import time
|
|
34
|
+
import urllib.error
|
|
35
|
+
import urllib.request
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from flashnode.executor.runner import TaskExecutionError
|
|
40
|
+
from flashnode.identity.store import state_dir
|
|
41
|
+
|
|
42
|
+
DEFAULT_BUDGET_GB = 16.0
|
|
43
|
+
# A floor on free space that must EXIST before a fetch starts. Read it
|
|
44
|
+
# literally: it is not a promise the gigabyte survives the fetch.
|
|
45
|
+
MIN_FREE_BYTES = 1024**3
|
|
46
|
+
_USED_MARKER = "used"
|
|
47
|
+
_SIZE_MARKER = "size"
|
|
48
|
+
_BUILD_PREFIX = ".fetching-"
|
|
49
|
+
|
|
50
|
+
#: Same shape flashnode already requires of an input name (loop.py) and a
|
|
51
|
+
#: local-data label. A dataset name becomes a directory on a stranger's disk.
|
|
52
|
+
_SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
53
|
+
|
|
54
|
+
#: Transport-level retry, the same shape and for the same measured reason as
|
|
55
|
+
#: `client._TRANSPORT_ATTEMPTS`: a single dropped connection on a volunteer's
|
|
56
|
+
#: home network must not cost the submitter their task.
|
|
57
|
+
#: What counts as "the origin hiccuped" rather than "the task is broken".
|
|
58
|
+
#:
|
|
59
|
+
#: `http.client.IncompleteRead` is here and is NOT in `client.py`'s
|
|
60
|
+
#: byte-identical-looking tuple, deliberately. It descends from
|
|
61
|
+
#: HTTPException, not OSError, so the obvious three-way tuple misses it —
|
|
62
|
+
#: and a CDN closing a connection early is the single most likely way a
|
|
63
|
+
#: large shard fetch fails. Uncaught it leaves `materialise`, misses
|
|
64
|
+
#: `_execute_inner`'s `except TaskExecutionError`, and lands in
|
|
65
|
+
#: `execute_one`'s last-line-of-defence handler, which says of itself:
|
|
66
|
+
#: "This DOES count against the host". So a flaky ORIGIN would be charged
|
|
67
|
+
#: to the volunteer and could quarantine their machine — inverting the one
|
|
68
|
+
#: rule this module exists to keep, that a broken origin costs the
|
|
69
|
+
#: submitter their task and never the volunteer their node.
|
|
70
|
+
#:
|
|
71
|
+
#: `client.py` has the same gap on the coordinator transport. Left alone
|
|
72
|
+
#: here on purpose: changing shipped retry semantics is a separate change
|
|
73
|
+
#: with its own evidence, not a side effect of adding datasets.
|
|
74
|
+
_TRANSIENT = (
|
|
75
|
+
urllib.error.URLError,
|
|
76
|
+
TimeoutError,
|
|
77
|
+
OSError,
|
|
78
|
+
http.client.IncompleteRead,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
_FETCH_ATTEMPTS = 4
|
|
82
|
+
_FETCH_BACKOFF_S = 0.5
|
|
83
|
+
_CHUNK = 1 << 20 # 1 MiB
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DatasetFetchError(TaskExecutionError):
|
|
87
|
+
"""A shard could not be fetched, verified, or stored.
|
|
88
|
+
|
|
89
|
+
Subclasses TaskExecutionError deliberately: the executor loop already
|
|
90
|
+
reports the cause through fail() before re-raising, so a broken origin
|
|
91
|
+
costs the submitter their task and never the volunteer their node.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class ShardEntry:
|
|
97
|
+
key: str
|
|
98
|
+
path: Path
|
|
99
|
+
size_bytes: int
|
|
100
|
+
last_used: float
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _free_bytes(path: Path) -> int:
|
|
104
|
+
# A module function, not an inline shutil call, so tests can patch it by
|
|
105
|
+
# dotted string — the seam test_environments.py already relies on.
|
|
106
|
+
return shutil.disk_usage(path).free
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def budget_bytes_from_env() -> int:
|
|
110
|
+
"""FLASHNODE_DATA_BUDGET_GB in bytes, or 0 if it is not a usable number.
|
|
111
|
+
|
|
112
|
+
THE one place this variable is parsed. It has two readers that must
|
|
113
|
+
never disagree: this cache, which decides what the host will actually
|
|
114
|
+
hold, and `inventory.capabilities.discover`, which decides what the
|
|
115
|
+
host ADVERTISES. Advertising more than the cache holds means accepting
|
|
116
|
+
work the cache then evicts mid-job, so a second copy of this arithmetic
|
|
117
|
+
is a bug waiting for a typo — the same duplicated-derivation trap
|
|
118
|
+
`elastic.py`'s docstring warns about.
|
|
119
|
+
|
|
120
|
+
Malformed reads as 0, never as the default. Fail closed: 0 is
|
|
121
|
+
indistinguishable on the wire from the owner's deliberate opt-out and
|
|
122
|
+
costs nobody a task, whereas silently substituting 16 GB would advertise
|
|
123
|
+
capacity the owner never agreed to lend.
|
|
124
|
+
|
|
125
|
+
`int()` is inside the guard, not just `float()`: `float("nan")` parses
|
|
126
|
+
happily and raises in `int()`, and `"inf"` raises OverflowError there.
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
return max(0, int(float(
|
|
130
|
+
os.environ.get("FLASHNODE_DATA_BUDGET_GB", DEFAULT_BUDGET_GB)
|
|
131
|
+
) * 1024**3))
|
|
132
|
+
except (TypeError, ValueError, OverflowError):
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class DatasetCache:
|
|
137
|
+
"""Shard checksum -> cached file, bounded and LRU-evicted."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, root: Path | None = None, budget_gb: float | None = None):
|
|
140
|
+
self.root = Path(root) if root is not None else state_dir() / "data"
|
|
141
|
+
self.budget_bytes = (
|
|
142
|
+
budget_bytes_from_env()
|
|
143
|
+
if budget_gb is None
|
|
144
|
+
else max(0, int(budget_gb * 1024**3))
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# -- public -----------------------------------------------------------
|
|
148
|
+
def get(self, integrity_value: str) -> Path | None:
|
|
149
|
+
"""The cached shard for this checksum, or None. Records a USE."""
|
|
150
|
+
entry = self.root / integrity_value
|
|
151
|
+
blob = entry / "blob"
|
|
152
|
+
if not blob.exists():
|
|
153
|
+
return None
|
|
154
|
+
self._touch(entry)
|
|
155
|
+
return blob
|
|
156
|
+
|
|
157
|
+
def put(self, integrity_value: str, source: Path) -> Path:
|
|
158
|
+
"""Publish `source` under `integrity_value`; return the stored path.
|
|
159
|
+
|
|
160
|
+
The move is a rename into place, the write-then-rename idiom
|
|
161
|
+
`environments.py` and `identity/store.py` both use, so a partial
|
|
162
|
+
entry is never published or returned.
|
|
163
|
+
"""
|
|
164
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
entry = self.root / integrity_value
|
|
166
|
+
size = Path(source).stat().st_size
|
|
167
|
+
scratch = self.root / f"{_BUILD_PREFIX}{integrity_value}"
|
|
168
|
+
if scratch.exists():
|
|
169
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
170
|
+
scratch.mkdir(parents=True)
|
|
171
|
+
try:
|
|
172
|
+
os.replace(source, scratch / "blob")
|
|
173
|
+
# Read-only, and load-bearing rather than tidy. `materialise`
|
|
174
|
+
# hard-links out of this cache, so the file a task sees at
|
|
175
|
+
# /work/data/ IS this blob — one inode, many jobs. A task writing
|
|
176
|
+
# to its own input therefore rewrites the CACHE, and every later
|
|
177
|
+
# job on this host gets the corrupted bytes as a HIT, with no
|
|
178
|
+
# re-verification and no signal. Measured before this line: job 1
|
|
179
|
+
# writes, job 2 silently trains on job 1's garbage.
|
|
180
|
+
#
|
|
181
|
+
# 0o444 turns that into an immediate EACCES in the job that tried
|
|
182
|
+
# it, which is the correct blame. Eviction still works: removing a
|
|
183
|
+
# file needs write permission on the DIRECTORY, not on the file.
|
|
184
|
+
(scratch / "blob").chmod(0o444)
|
|
185
|
+
(scratch / _SIZE_MARKER).write_text(str(size))
|
|
186
|
+
self._touch(scratch)
|
|
187
|
+
if entry.exists(): # wreckage from an interrupted run
|
|
188
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
189
|
+
scratch.replace(entry)
|
|
190
|
+
except BaseException:
|
|
191
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
192
|
+
raise
|
|
193
|
+
self._evict(protect=entry)
|
|
194
|
+
return entry / "blob"
|
|
195
|
+
|
|
196
|
+
def reserve(self, nbytes: int) -> None:
|
|
197
|
+
"""Prune, then refuse unless the disk can take `nbytes`.
|
|
198
|
+
|
|
199
|
+
Pruning first is load-bearing, not an optimisation: `_evict`
|
|
200
|
+
otherwise runs only after a successful put, so a node that dips
|
|
201
|
+
under the floor can never fetch, therefore never evicts, therefore
|
|
202
|
+
never recovers.
|
|
203
|
+
|
|
204
|
+
Unlike EnvironmentCache's floor — which cannot know an install's
|
|
205
|
+
size until the resolver has run — a manifest entry states its own
|
|
206
|
+
`size`, so the bytes about to arrive are counted here as well as
|
|
207
|
+
the floor beneath them.
|
|
208
|
+
"""
|
|
209
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
if _free_bytes(self.root) - nbytes >= MIN_FREE_BYTES:
|
|
211
|
+
return
|
|
212
|
+
self._evict()
|
|
213
|
+
free = _free_bytes(self.root)
|
|
214
|
+
if free - nbytes < MIN_FREE_BYTES:
|
|
215
|
+
raise DatasetFetchError(
|
|
216
|
+
f"refusing to fetch {nbytes // 1024**2} MB of dataset: after "
|
|
217
|
+
f"pruning the shard cache to its "
|
|
218
|
+
f"{self.budget_bytes // 1024**2} MB budget, only "
|
|
219
|
+
f"{free // 1024**2} MB is free on disk at {self.root}, and "
|
|
220
|
+
f"this node will not start a fetch that would leave less "
|
|
221
|
+
f"than {MIN_FREE_BYTES // 1024**2} MB free. Free disk space, "
|
|
222
|
+
f"or lower FLASHNODE_DATA_BUDGET_GB so the next attempt "
|
|
223
|
+
f"prunes further."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def entries(self) -> list[ShardEntry]:
|
|
227
|
+
"""Cached shards, most recently used first."""
|
|
228
|
+
if not self.root.is_dir():
|
|
229
|
+
return []
|
|
230
|
+
found = []
|
|
231
|
+
for path in self.root.iterdir():
|
|
232
|
+
if not path.is_dir() or path.name.startswith("."):
|
|
233
|
+
continue
|
|
234
|
+
# The written marker, not a tree walk. `_evict` calls this on
|
|
235
|
+
# every put, and a shard directory can hold a million files.
|
|
236
|
+
# The fallback covers an entry whose marker was lost with the
|
|
237
|
+
# process that should have written it: one stat is still cheap,
|
|
238
|
+
# and reporting 0 would make `_evict` unable to shrink.
|
|
239
|
+
try:
|
|
240
|
+
size = int((path / _SIZE_MARKER).read_text())
|
|
241
|
+
except (OSError, ValueError):
|
|
242
|
+
blob = path / "blob"
|
|
243
|
+
size = blob.stat().st_size if blob.exists() else 0
|
|
244
|
+
# Seconds, deliberately: `agent/cli.py` renders this with
|
|
245
|
+
# `datetime.fromtimestamp`. The PRECISION comes from the written
|
|
246
|
+
# nanosecond stamp — see `_touch`; the unit does not change.
|
|
247
|
+
try:
|
|
248
|
+
last_used = int((path / _USED_MARKER).read_text()) / 1e9
|
|
249
|
+
except (OSError, ValueError):
|
|
250
|
+
last_used = path.stat().st_mtime
|
|
251
|
+
found.append(ShardEntry(path.name, path, size, last_used))
|
|
252
|
+
return sorted(found, key=lambda e: e.last_used, reverse=True)
|
|
253
|
+
|
|
254
|
+
def stale_dirs(self) -> list[Path]:
|
|
255
|
+
"""Scratch directories left by fetches that were killed outright.
|
|
256
|
+
|
|
257
|
+
Deliberately NOT folded into `entries()`, for the reason
|
|
258
|
+
`EnvironmentCache.stale_build_dirs` gives: those are cache entries
|
|
259
|
+
`_evict` deletes by age to stay inside the budget, and a half-written
|
|
260
|
+
shard has no `used` marker and is not a cacheable thing. Reported
|
|
261
|
+
separately so the owner sees the bytes rather than the cache quietly
|
|
262
|
+
under-reporting what it holds.
|
|
263
|
+
"""
|
|
264
|
+
if not self.root.is_dir():
|
|
265
|
+
return []
|
|
266
|
+
return sorted(
|
|
267
|
+
p for p in self.root.iterdir()
|
|
268
|
+
if p.is_dir() and p.name.startswith(_BUILD_PREFIX)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def stale_bytes(self) -> int:
|
|
272
|
+
return sum(
|
|
273
|
+
sum(f.stat().st_size for f in p.rglob("*") if f.is_file())
|
|
274
|
+
for p in self.stale_dirs()
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def sweep_stale(self) -> int:
|
|
278
|
+
"""Delete wreckage; return bytes actually reclaimed (counted after)."""
|
|
279
|
+
freed = 0
|
|
280
|
+
for path in self.stale_dirs():
|
|
281
|
+
size = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
|
282
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
283
|
+
if not path.exists():
|
|
284
|
+
freed += size
|
|
285
|
+
return freed
|
|
286
|
+
|
|
287
|
+
# -- internals --------------------------------------------------------
|
|
288
|
+
def _touch(self, entry: Path) -> None:
|
|
289
|
+
"""Record a USE with a written nanosecond stamp.
|
|
290
|
+
|
|
291
|
+
NOT mtime. Linux stamps inodes from a coarse clock, so two events
|
|
292
|
+
microseconds apart get byte-identical st_mtime_ns; `sorted` is
|
|
293
|
+
stable, so the tie fell through to iterdir() order, which ext4
|
|
294
|
+
hashes. The result was an LRU that evicted its most recently used
|
|
295
|
+
entry. See EnvironmentCache._touch — do not simplify this.
|
|
296
|
+
"""
|
|
297
|
+
(entry / _USED_MARKER).write_text(str(time.time_ns()))
|
|
298
|
+
|
|
299
|
+
def _evict(self, protect: Path | None = None) -> None:
|
|
300
|
+
"""Delete least-recently-used entries until within budget.
|
|
301
|
+
|
|
302
|
+
`protect` is the entry a caller is about to be handed. Pruning to
|
|
303
|
+
make room takes no such argument: nothing is in use at that moment,
|
|
304
|
+
so the cache may empty itself if that is what the budget now says.
|
|
305
|
+
"""
|
|
306
|
+
entries = self.entries() # most recently used first
|
|
307
|
+
total = sum(e.size_bytes for e in entries)
|
|
308
|
+
for entry in reversed(entries): # oldest first
|
|
309
|
+
if total <= self.budget_bytes:
|
|
310
|
+
return
|
|
311
|
+
if entry.path == protect:
|
|
312
|
+
continue
|
|
313
|
+
shutil.rmtree(entry.path, ignore_errors=True)
|
|
314
|
+
total -= entry.size_bytes
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _open_url(url: str, offset: int):
|
|
318
|
+
"""Open `url`, optionally resuming at `offset`. The single network seam.
|
|
319
|
+
|
|
320
|
+
Deliberately NOT `client._request`: that prefixes the coordinator base
|
|
321
|
+
URL and attaches the agent's bearer token, and a dataset origin is a
|
|
322
|
+
third party that must never see it.
|
|
323
|
+
|
|
324
|
+
A module function, and the ONLY place this module touches the network,
|
|
325
|
+
so every test above patches it by dotted string and the real thing runs
|
|
326
|
+
only under `-m integration`.
|
|
327
|
+
"""
|
|
328
|
+
request = urllib.request.Request(url, headers={"User-Agent": "flashnode"})
|
|
329
|
+
if offset:
|
|
330
|
+
request.add_header("Range", f"bytes={offset}-")
|
|
331
|
+
return urllib.request.urlopen(request, timeout=60)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def fetch_shard(entry: dict, cache: DatasetCache, scratch: Path) -> Path:
|
|
335
|
+
"""Return a local path to this manifest entry's bytes, fetching if needed."""
|
|
336
|
+
integrity = entry.get("integrity") or {}
|
|
337
|
+
kind = integrity.get("kind")
|
|
338
|
+
value = integrity.get("value")
|
|
339
|
+
if not isinstance(value, str) or not value:
|
|
340
|
+
raise DatasetFetchError(
|
|
341
|
+
f"manifest entry {entry.get('path')!r} has no integrity value"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
cached = cache.get(value)
|
|
345
|
+
if cached is not None:
|
|
346
|
+
return cached
|
|
347
|
+
|
|
348
|
+
size = entry.get("size")
|
|
349
|
+
cache.reserve(int(size) if isinstance(size, int) and size > 0 else 0)
|
|
350
|
+
|
|
351
|
+
scratch.mkdir(parents=True, exist_ok=True)
|
|
352
|
+
target = scratch / "blob"
|
|
353
|
+
written = 0
|
|
354
|
+
last: Exception | None = None
|
|
355
|
+
for attempt in range(_FETCH_ATTEMPTS):
|
|
356
|
+
try:
|
|
357
|
+
with _open_url(entry["url"], written) as resp:
|
|
358
|
+
mode = "ab" if written else "wb"
|
|
359
|
+
with open(target, mode) as fh:
|
|
360
|
+
# copyfileobj, never resp.read(): a multi-GB shard must
|
|
361
|
+
# not be assembled in RAM on a volunteer's laptop.
|
|
362
|
+
shutil.copyfileobj(resp, fh, _CHUNK)
|
|
363
|
+
written = target.stat().st_size
|
|
364
|
+
break
|
|
365
|
+
except _TRANSIENT as exc:
|
|
366
|
+
last = exc
|
|
367
|
+
# Resume from whatever landed. An origin that ignores the Range
|
|
368
|
+
# header and re-serves the whole body will produce a wrong file,
|
|
369
|
+
# not a silently wrong one — the checksum below catches it.
|
|
370
|
+
written = target.stat().st_size if target.exists() else 0
|
|
371
|
+
if attempt < _FETCH_ATTEMPTS - 1:
|
|
372
|
+
time.sleep(_FETCH_BACKOFF_S * (2 ** attempt))
|
|
373
|
+
else:
|
|
374
|
+
raise DatasetFetchError(
|
|
375
|
+
f"could not fetch {entry.get('path')!r} after {_FETCH_ATTEMPTS} "
|
|
376
|
+
f"attempts: {last}"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# sha256 is the only kind we can prove. An ETag is md5-of-md5s for a
|
|
380
|
+
# multipart upload, so asserting on it would fail every valid multipart
|
|
381
|
+
# object; it is a change-detection token the control plane already used
|
|
382
|
+
# to pin the revision, not something to verify here.
|
|
383
|
+
# `declared-sha256` is verified exactly like `sha256`. The two differ in
|
|
384
|
+
# PROVENANCE, not in arithmetic: one hash came from the origin's own API,
|
|
385
|
+
# the other from a manifest the submitter wrote. Neither is a reason to
|
|
386
|
+
# skip the check — a declared hash is the only integrity signal an
|
|
387
|
+
# arbitrary HTTPS origin offers at all, so not checking it would leave
|
|
388
|
+
# that whole scheme with no verification of any kind.
|
|
389
|
+
#
|
|
390
|
+
# An ETag stays unverified: for a multipart upload it is md5-of-md5s, so
|
|
391
|
+
# asserting on it would fail every valid multipart object.
|
|
392
|
+
if kind in ("sha256", "declared-sha256"):
|
|
393
|
+
computed = hashlib.sha256()
|
|
394
|
+
with open(target, "rb") as fh:
|
|
395
|
+
# Streamed, like the download: hashing via read_bytes() would
|
|
396
|
+
# undo the whole point of copyfileobj above.
|
|
397
|
+
for block in iter(lambda: fh.read(_CHUNK), b""):
|
|
398
|
+
computed.update(block)
|
|
399
|
+
if computed.hexdigest() != value:
|
|
400
|
+
target.unlink(missing_ok=True)
|
|
401
|
+
raise DatasetFetchError(
|
|
402
|
+
f"checksum mismatch for {entry.get('path')!r}: the origin "
|
|
403
|
+
f"served bytes that are not what the manifest pinned "
|
|
404
|
+
f"(wanted {value[:16]}…, got {computed.hexdigest()[:16]}…). "
|
|
405
|
+
f"The dataset may have changed since the job was submitted."
|
|
406
|
+
)
|
|
407
|
+
return cache.put(value, target)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def materialise(datasets: list[dict], cache: DatasetCache, workdir: Path) -> None:
|
|
411
|
+
"""Lay every declared dataset out under `workdir/data/<name>/`.
|
|
412
|
+
|
|
413
|
+
Hard-links from the cache where the filesystem allows it and copies
|
|
414
|
+
otherwise, so N tasks sharing a shard cost one copy on disk.
|
|
415
|
+
"""
|
|
416
|
+
for dataset in datasets or []:
|
|
417
|
+
name = dataset.get("name")
|
|
418
|
+
if (
|
|
419
|
+
not isinstance(name, str)
|
|
420
|
+
or not _SAFE_NAME.match(name)
|
|
421
|
+
or name in (".", "..")
|
|
422
|
+
):
|
|
423
|
+
raise DatasetFetchError(
|
|
424
|
+
f"refusing a dataset with unsafe name {name!r}: a name is not "
|
|
425
|
+
f"a path"
|
|
426
|
+
)
|
|
427
|
+
root = (workdir / "data" / name).resolve()
|
|
428
|
+
for entry in dataset.get("entries") or []:
|
|
429
|
+
rel = str(entry.get("path") or "")
|
|
430
|
+
dest = (root / rel).resolve()
|
|
431
|
+
# Resolve THEN require containment. A naive join misses the
|
|
432
|
+
# absolute form entirely: Path("/work/data") / "/etc/x" is
|
|
433
|
+
# "/etc/x" — pathlib discards the left operand. Same reasoning
|
|
434
|
+
# as archives.py.
|
|
435
|
+
#
|
|
436
|
+
# Strictly INSIDE, never equal: an entry whose path is empty or
|
|
437
|
+
# "." names the dataset directory itself, and linking a blob
|
|
438
|
+
# onto a directory raises a bare OSError out of here. A broken
|
|
439
|
+
# manifest must fail the task as a typed refusal.
|
|
440
|
+
if not str(dest).startswith(str(root) + os.sep):
|
|
441
|
+
raise DatasetFetchError(
|
|
442
|
+
f"refusing dataset entry {rel!r}: it resolves outside "
|
|
443
|
+
f"{root}"
|
|
444
|
+
)
|
|
445
|
+
blob = fetch_shard(entry, cache, workdir / ".fetching" / name)
|
|
446
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
447
|
+
try:
|
|
448
|
+
os.link(blob, dest)
|
|
449
|
+
except OSError:
|
|
450
|
+
# A separate filesystem, a hard-link-hostile one, or the
|
|
451
|
+
# destination already there from a previous task on this
|
|
452
|
+
# workdir. A copy is slower, never wrong.
|
|
453
|
+
shutil.copyfile(blob, dest)
|
|
454
|
+
# A copy is a NEW inode and would default to writable, so the
|
|
455
|
+
# link and copy paths would disagree about whether a task can
|
|
456
|
+
# scribble on its own input. Freeze it to match.
|
|
457
|
+
dest.chmod(0o444)
|
|
@@ -431,6 +431,37 @@ class ExecutorLoop:
|
|
|
431
431
|
f"unpack_inputs names inputs that do not exist: {sorted(unknown)}"
|
|
432
432
|
)
|
|
433
433
|
|
|
434
|
+
declared = payload.get("datasets")
|
|
435
|
+
if declared:
|
|
436
|
+
# Shape-checked first, exactly as `unpack_inputs` is
|
|
437
|
+
# above and for the same reason: the payload is
|
|
438
|
+
# attacker-influenced all the way from the submission,
|
|
439
|
+
# and `materialise` reaches for `.get` on every element.
|
|
440
|
+
# A `datasets` that is not a list of dicts would raise a
|
|
441
|
+
# bare AttributeError, miss the `except TaskExecutionError`
|
|
442
|
+
# below, never reach fail(), and land in `execute_one`'s
|
|
443
|
+
# last-line-of-defence — which counts against the HOST.
|
|
444
|
+
# A broken payload must cost the submitter their task.
|
|
445
|
+
if not isinstance(declared, list) or not all(
|
|
446
|
+
isinstance(d, dict) for d in declared
|
|
447
|
+
):
|
|
448
|
+
raise TaskExecutionError(
|
|
449
|
+
"payload 'datasets' must be a list of dataset slices"
|
|
450
|
+
)
|
|
451
|
+
# Before the runner, always. The task runs with
|
|
452
|
+
# `--network none`, so a fetch after the sandbox closes
|
|
453
|
+
# is a fetch that never happens.
|
|
454
|
+
#
|
|
455
|
+
# Imported here, not at module scope, following the rule
|
|
456
|
+
# trusted_runner.py already follows for `environments`.
|
|
457
|
+
from flashnode.executor import datasets as datasets_mod
|
|
458
|
+
|
|
459
|
+
datasets_mod.materialise(
|
|
460
|
+
declared, datasets_mod.DatasetCache(), workdir,
|
|
461
|
+
)
|
|
462
|
+
log.info(_jlog("datasets ready", task=lease.task_id,
|
|
463
|
+
count=len(declared)))
|
|
464
|
+
|
|
434
465
|
prefix = payload.get("output_prefix", f"jobs/{lease.job_id}/{lease.task_id}/")
|
|
435
466
|
if payload.get("checkpoint") is not None:
|
|
436
467
|
# resume from the task's latest valid checkpoint, wherever
|