manta-node 0.6b5.dev705__py3-none-any.whl → 0.6b5.dev707__py3-none-any.whl
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.
- manta_node/cli/_version_generated.py +3 -3
- manta_node/supervisor/lifecycle.py +56 -0
- manta_node/supervisor/process.py +39 -0
- manta_node/supervisor/switch.py +240 -4
- {manta_node-0.6b5.dev705.dist-info → manta_node-0.6b5.dev707.dist-info}/METADATA +1 -1
- {manta_node-0.6b5.dev705.dist-info → manta_node-0.6b5.dev707.dist-info}/RECORD +10 -10
- {manta_node-0.6b5.dev705.dist-info → manta_node-0.6b5.dev707.dist-info}/WHEEL +0 -0
- {manta_node-0.6b5.dev705.dist-info → manta_node-0.6b5.dev707.dist-info}/entry_points.txt +0 -0
- {manta_node-0.6b5.dev705.dist-info → manta_node-0.6b5.dev707.dist-info}/licenses/LICENSE +0 -0
- {manta_node-0.6b5.dev705.dist-info → manta_node-0.6b5.dev707.dist-info}/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ This file is auto-generated at build time by scripts/generate_version.py.
|
|
|
4
4
|
Do not edit manually.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "2026.09.
|
|
8
|
-
__build_date__ = "2026.09.
|
|
9
|
-
__commit_id__ = "
|
|
7
|
+
__version__ = "2026.09.25.d14aac9a"
|
|
8
|
+
__build_date__ = "2026.09.25"
|
|
9
|
+
__commit_id__ = "d14aac9a"
|
|
10
10
|
__package_version__ = "0.6b5"
|
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
import time
|
|
21
21
|
from collections.abc import Callable
|
|
22
22
|
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
23
24
|
from typing import Protocol
|
|
24
25
|
|
|
25
26
|
from ..seam import SeamState, SwitchOutcome, UpdateRequest
|
|
@@ -28,6 +29,11 @@ from .bounds import Bounds
|
|
|
28
29
|
from .inflight import InFlight, clear_inflight, deadline_from_now, write_inflight
|
|
29
30
|
from .switch import VersionTreeError
|
|
30
31
|
|
|
32
|
+
# The free-space floor to stage a version dir. A version dir holds a full
|
|
33
|
+
# dependency closure, so the check refuses well before the disk is full rather
|
|
34
|
+
# than half-staging (NF-12). Operator-overridable via `run_switch`'s argument.
|
|
35
|
+
MIN_FREE_BYTES = 500 * 1024 * 1024
|
|
36
|
+
|
|
31
37
|
|
|
32
38
|
class LifecycleError(Exception):
|
|
33
39
|
"""A lifecycle step failed; the node stays on its current version (NF-3)."""
|
|
@@ -88,6 +94,7 @@ def run_switch(
|
|
|
88
94
|
is_back: ReturnProbe,
|
|
89
95
|
bounds: Bounds | None = None,
|
|
90
96
|
now: Callable[[], float] = time.time,
|
|
97
|
+
min_free_bytes: int = MIN_FREE_BYTES,
|
|
91
98
|
) -> SwitchOutcome:
|
|
92
99
|
"""Run the lifecycle for one request and return its terminal outcome.
|
|
93
100
|
|
|
@@ -121,6 +128,16 @@ def run_switch(
|
|
|
121
128
|
if not _within(bounds.verify, lambda: verify(candidate_dir, candidate_python)):
|
|
122
129
|
return _outcome(request, SeamState.FAILED, "verification failed (no switch)")
|
|
123
130
|
|
|
131
|
+
# FREE SPACE — refuse before staging when there is no room for a full version
|
|
132
|
+
# dir (NF-12, §6.2). Refusing here, before any write, is what stops a
|
|
133
|
+
# half-staged tree: the node stays on its current version.
|
|
134
|
+
if not version_tree.has_room_for(min_free_bytes):
|
|
135
|
+
return _outcome(
|
|
136
|
+
request,
|
|
137
|
+
SeamState.REFUSED,
|
|
138
|
+
"insufficient free space to stage a version dir",
|
|
139
|
+
)
|
|
140
|
+
|
|
124
141
|
# STAGE — materialise the candidate's version dir (idempotent), bounded.
|
|
125
142
|
staged: dict = {}
|
|
126
143
|
|
|
@@ -145,11 +162,13 @@ def run_switch(
|
|
|
145
162
|
try:
|
|
146
163
|
if not _within(bounds.switch, lambda: version_tree.commit(target)):
|
|
147
164
|
clear_inflight()
|
|
165
|
+
_reclaim_candidate(bounds, target, last_good_dir)
|
|
148
166
|
return _outcome(
|
|
149
167
|
request, SeamState.FAILED, "switch exceeded its bound (repoint failed)"
|
|
150
168
|
)
|
|
151
169
|
except VersionTreeError as exc:
|
|
152
170
|
clear_inflight()
|
|
171
|
+
_reclaim_candidate(bounds, target, last_good_dir)
|
|
153
172
|
return _outcome(request, SeamState.FAILED, f"switch failed: {exc}")
|
|
154
173
|
|
|
155
174
|
# RESTART onto the committed version, then wait for the return window. A
|
|
@@ -157,10 +176,18 @@ def run_switch(
|
|
|
157
176
|
# the record rather than letting the exception escape.
|
|
158
177
|
if not _within(bounds.switch, restart):
|
|
159
178
|
clear_inflight()
|
|
179
|
+
_reclaim_candidate(bounds, target, last_good_dir)
|
|
160
180
|
return _outcome(request, SeamState.DOWN, "restart failed after the switch")
|
|
161
181
|
|
|
162
182
|
if is_back(record.deadline_epoch):
|
|
163
183
|
clear_inflight()
|
|
184
|
+
# Reclaim on a SUCCESSFUL switch (NF-12/B-34): the tree stays bounded, and
|
|
185
|
+
# the current and last-good versions are never removed. No separate
|
|
186
|
+
# sweeper: reclamation rides the switch that made a version superseded.
|
|
187
|
+
protect = {target}
|
|
188
|
+
if last_good_dir is not None:
|
|
189
|
+
protect.add(last_good_dir)
|
|
190
|
+
version_tree.reclaim(bounds.retention_dirs, protect)
|
|
164
191
|
return _outcome(
|
|
165
192
|
request,
|
|
166
193
|
SeamState.REPORTING,
|
|
@@ -172,6 +199,7 @@ def run_switch(
|
|
|
172
199
|
if last_good is None:
|
|
173
200
|
# Nothing to revert to: surface DOWN rather than claim success.
|
|
174
201
|
clear_inflight()
|
|
202
|
+
_reclaim_candidate(bounds, target, last_good_dir)
|
|
175
203
|
return _outcome(
|
|
176
204
|
request, SeamState.DOWN, "no return and no last-good version to revert to"
|
|
177
205
|
)
|
|
@@ -181,8 +209,13 @@ def run_switch(
|
|
|
181
209
|
raise LifecycleError("restart after revert failed")
|
|
182
210
|
except (VersionTreeError, LifecycleError) as exc:
|
|
183
211
|
clear_inflight()
|
|
212
|
+
_reclaim_candidate(bounds, target, last_good_dir)
|
|
184
213
|
return _outcome(request, SeamState.DOWN, f"revert failed: {exc}")
|
|
185
214
|
clear_inflight()
|
|
215
|
+
# The candidate was superseded the moment the switch did not land: it is
|
|
216
|
+
# reclaimed even though the switch failed, so repeated failing switches do
|
|
217
|
+
# not grow the tree past the bound (NF-12). current/last-good are protected.
|
|
218
|
+
_reclaim_candidate(bounds, target, last_good_dir)
|
|
186
219
|
return _outcome(
|
|
187
220
|
request,
|
|
188
221
|
SeamState.REVERTED,
|
|
@@ -191,6 +224,29 @@ def run_switch(
|
|
|
191
224
|
)
|
|
192
225
|
|
|
193
226
|
|
|
227
|
+
def _reclaim_candidate(
|
|
228
|
+
bounds: Bounds, candidate: Path, last_good_dir: Path | None
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Reclaim after a switch that did NOT land: the candidate is superseded.
|
|
231
|
+
|
|
232
|
+
Called on every FAILED/REVERTED/DOWN path after staging, so repeated failing
|
|
233
|
+
switches do not grow the tree past ``retention_dirs`` (NF-12). The candidate is
|
|
234
|
+
removed first, then ``reclaim`` bounds the rest, protecting ``current`` and the
|
|
235
|
+
last good.
|
|
236
|
+
|
|
237
|
+
Whether the candidate is still current depends on the branch: a failed commit
|
|
238
|
+
leaves it non-current, but a switch whose commit succeeded and whose RESTART
|
|
239
|
+
then failed (or found no last good to revert to) has ``target`` AS ``current``.
|
|
240
|
+
``remove_version`` therefore refuses to delete whatever ``current`` names, and
|
|
241
|
+
that check — not any assumption here — is what keeps the node runnable on those
|
|
242
|
+
DOWN paths. Keep it if the candidate could ever be current.
|
|
243
|
+
"""
|
|
244
|
+
version_tree.remove_version(candidate)
|
|
245
|
+
current = version_tree.selected()
|
|
246
|
+
protect = {p for p in (current, last_good_dir) if p is not None}
|
|
247
|
+
version_tree.reclaim(bounds.retention_dirs, protect)
|
|
248
|
+
|
|
249
|
+
|
|
194
250
|
def _within(bound: float, step: Callable[[], None]) -> bool:
|
|
195
251
|
"""Run ``step``; True if it completed, False if it raised or exceeded ``bound``.
|
|
196
252
|
|
manta_node/supervisor/process.py
CHANGED
|
@@ -40,9 +40,11 @@ from ..seam import (
|
|
|
40
40
|
UpdateRequest,
|
|
41
41
|
seam_socket_path,
|
|
42
42
|
)
|
|
43
|
+
from . import switch as version_tree
|
|
43
44
|
from .bounds import load_bounds
|
|
44
45
|
from .executor import ChildControl, make_executor
|
|
45
46
|
from .inflight import read_inflight
|
|
47
|
+
from .lifecycle import MIN_FREE_BYTES
|
|
46
48
|
from .records import (
|
|
47
49
|
supervisor_record,
|
|
48
50
|
supervisor_record_path,
|
|
@@ -358,10 +360,47 @@ def apply_command(argv: list[str]) -> int:
|
|
|
358
360
|
return 0 if outcome.state not in (SeamState.FAILED, SeamState.DOWN) else 1
|
|
359
361
|
|
|
360
362
|
|
|
363
|
+
def preflight_command(argv: list[str]) -> int:
|
|
364
|
+
"""`manta-supervisor preflight <pair>` — may this node take another version?
|
|
365
|
+
|
|
366
|
+
**Local, not a socket verb.** It reads the node's own version tree and applies
|
|
367
|
+
the supervisor's room policy *without writing anything*; it needs no running
|
|
368
|
+
supervisor, so the fleet path can ask before it creates the version dir and the
|
|
369
|
+
refusal is early (NF-12 has teeth). The deploy only asks; the supervisor owns
|
|
370
|
+
the policy.
|
|
371
|
+
|
|
372
|
+
Output mirrors ``apply`` but echoes the pair: ``<state>: <pair>: <reason>``
|
|
373
|
+
(``ok`` / ``refused``), exiting **0** either way — the state is the verdict,
|
|
374
|
+
not the exit code.
|
|
375
|
+
"""
|
|
376
|
+
parser = argparse.ArgumentParser(
|
|
377
|
+
prog="manta-supervisor preflight",
|
|
378
|
+
description="Check room for one more version dir before installing it.",
|
|
379
|
+
)
|
|
380
|
+
parser.add_argument("pair", help="the pair to be staged (for the message)")
|
|
381
|
+
parser.add_argument(
|
|
382
|
+
"--min-free-bytes",
|
|
383
|
+
type=int,
|
|
384
|
+
default=None,
|
|
385
|
+
help="override the free-space floor (defaults to the configured floor)",
|
|
386
|
+
)
|
|
387
|
+
args = parser.parse_args(argv)
|
|
388
|
+
|
|
389
|
+
floor = args.min_free_bytes if args.min_free_bytes is not None else MIN_FREE_BYTES
|
|
390
|
+
result = version_tree.preflight(floor)
|
|
391
|
+
state = "ok" if result.ok else "refused"
|
|
392
|
+
# The pair is echoed so an operator running preflight across several pairs
|
|
393
|
+
# can tell which line is which.
|
|
394
|
+
print(f"{state}: {args.pair}: {result.reason}")
|
|
395
|
+
return 0
|
|
396
|
+
|
|
397
|
+
|
|
361
398
|
def main(argv: list[str] | None = None) -> int:
|
|
362
399
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
363
400
|
if argv and argv[0] == "apply":
|
|
364
401
|
return apply_command(argv[1:])
|
|
402
|
+
if argv and argv[0] == "preflight":
|
|
403
|
+
return preflight_command(argv[1:])
|
|
365
404
|
|
|
366
405
|
parser = argparse.ArgumentParser(
|
|
367
406
|
prog="manta-supervisor",
|
manta_node/supervisor/switch.py
CHANGED
|
@@ -22,7 +22,9 @@ the token, config or datasets.
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
24
|
import os
|
|
25
|
-
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
from dataclasses import dataclass, replace
|
|
26
28
|
from datetime import datetime, timezone
|
|
27
29
|
from importlib.metadata import PackageNotFoundError, version
|
|
28
30
|
from pathlib import Path
|
|
@@ -70,19 +72,31 @@ def current_link() -> Path:
|
|
|
70
72
|
|
|
71
73
|
@dataclass(frozen=True)
|
|
72
74
|
class Version:
|
|
73
|
-
"""A version dir's identity: the pair
|
|
75
|
+
"""A version dir's identity: the pair, the interpreter, and its size.
|
|
76
|
+
|
|
77
|
+
``size_bytes`` is recorded once (see ``version_dir_bytes``) so the room check
|
|
78
|
+
is a manifest read, not a recursive walk of the dependency closure on every
|
|
79
|
+
switch. A version dir is immutable (NF-1), so a recorded size stays valid.
|
|
80
|
+
"""
|
|
74
81
|
|
|
75
82
|
pair: str
|
|
76
83
|
python: str
|
|
77
84
|
created: str
|
|
85
|
+
size_bytes: int | None = None
|
|
78
86
|
|
|
79
87
|
def to_dict(self) -> dict:
|
|
80
|
-
|
|
88
|
+
data = {"pair": self.pair, "python": self.python, "created": self.created}
|
|
89
|
+
if self.size_bytes is not None:
|
|
90
|
+
data["size_bytes"] = self.size_bytes
|
|
91
|
+
return data
|
|
81
92
|
|
|
82
93
|
@classmethod
|
|
83
94
|
def from_dict(cls, data: dict) -> Version:
|
|
84
95
|
return cls(
|
|
85
|
-
pair=data["pair"],
|
|
96
|
+
pair=data["pair"],
|
|
97
|
+
python=data["python"],
|
|
98
|
+
created=data.get("created", ""),
|
|
99
|
+
size_bytes=data.get("size_bytes"),
|
|
86
100
|
)
|
|
87
101
|
|
|
88
102
|
|
|
@@ -199,3 +213,225 @@ def is_pointer_target(target: Path) -> bool:
|
|
|
199
213
|
"""True when ``current`` resolves to ``target``."""
|
|
200
214
|
link = current_link()
|
|
201
215
|
return link.is_symlink() and Path(os.path.realpath(link)) == target
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def list_versions() -> list[Path]:
|
|
219
|
+
"""The version dirs, oldest first.
|
|
220
|
+
|
|
221
|
+
Ordered by the manifest's ``created`` (a dir with no readable manifest sorts
|
|
222
|
+
first, so a corrupt one is reclaimed before a good one). Used by retention to
|
|
223
|
+
pick what to reclaim.
|
|
224
|
+
"""
|
|
225
|
+
root = versions_dir()
|
|
226
|
+
if not root.is_dir():
|
|
227
|
+
return []
|
|
228
|
+
|
|
229
|
+
def created(p: Path) -> str:
|
|
230
|
+
manifest = read_manifest(p)
|
|
231
|
+
return manifest.created if manifest else ""
|
|
232
|
+
|
|
233
|
+
dirs = [p for p in root.iterdir() if p.is_dir()]
|
|
234
|
+
return sorted(dirs, key=created)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def remove_version(version_dir: Path) -> bool:
|
|
238
|
+
"""Remove one version dir and its contents; True if it is gone.
|
|
239
|
+
|
|
240
|
+
Best-effort, like ``reclaim``: a dir that cannot be removed is reported, not
|
|
241
|
+
raised — a stale dir must not fail the switch that is trying to discard it.
|
|
242
|
+
"""
|
|
243
|
+
# Never remove the version `current` names, even if a caller asks: the boot
|
|
244
|
+
# path must always resolve to something runnable.
|
|
245
|
+
if os.path.realpath(version_dir) == os.path.realpath(current_link()):
|
|
246
|
+
return False
|
|
247
|
+
try:
|
|
248
|
+
for child in version_dir.iterdir():
|
|
249
|
+
if child.is_dir():
|
|
250
|
+
shutil.rmtree(child)
|
|
251
|
+
else:
|
|
252
|
+
child.unlink()
|
|
253
|
+
version_dir.rmdir()
|
|
254
|
+
return True
|
|
255
|
+
except OSError as exc:
|
|
256
|
+
print(f"retention: could not remove {version_dir}: {exc}", file=sys.stderr)
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def reclaim(retention_dirs: int, protect: set[Path]) -> list[Path]:
|
|
261
|
+
"""Remove the oldest versions beyond ``retention_dirs``, never a protected one.
|
|
262
|
+
|
|
263
|
+
NF-12/B-34: the version tree stays bounded, but the **current** and last-good
|
|
264
|
+
versions are never removed — ``protect`` names them. Returns the removed dirs.
|
|
265
|
+
Called on a successful switch, so there is no separate sweeper to start.
|
|
266
|
+
"""
|
|
267
|
+
versions = list_versions()
|
|
268
|
+
protected = {os.path.realpath(p) for p in protect}
|
|
269
|
+
# Oldest first; drop the front until the count is within the bound, skipping
|
|
270
|
+
# a protected dir (do not count it as removable, but it still occupies a slot).
|
|
271
|
+
removed: list[Path] = []
|
|
272
|
+
keep = max(retention_dirs, 2) # current + last good is the floor
|
|
273
|
+
for candidate in versions:
|
|
274
|
+
if len(versions) - len(removed) <= keep:
|
|
275
|
+
break
|
|
276
|
+
if os.path.realpath(candidate) in protected:
|
|
277
|
+
continue
|
|
278
|
+
if remove_version(candidate):
|
|
279
|
+
removed.append(candidate)
|
|
280
|
+
return removed
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _free_bytes() -> int:
|
|
284
|
+
"""Free bytes on the version tree's filesystem.
|
|
285
|
+
|
|
286
|
+
Checks the nearest existing ancestor: the tree may not exist yet on a fresh
|
|
287
|
+
node, and the capacity is the filesystem's either way.
|
|
288
|
+
"""
|
|
289
|
+
probe = versions_dir()
|
|
290
|
+
while not probe.exists() and probe != probe.parent:
|
|
291
|
+
probe = probe.parent
|
|
292
|
+
return shutil.disk_usage(probe).free
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def version_dir_bytes(version_dir: Path) -> int:
|
|
296
|
+
"""The on-disk size of a version dir.
|
|
297
|
+
|
|
298
|
+
Cached in the manifest once measured: a version dir is immutable (NF-1), so
|
|
299
|
+
the size does not change, and a switch on an ARM edge node must not re-walk a
|
|
300
|
+
full dependency closure. The first read measures and writes it back; every
|
|
301
|
+
later read is a manifest lookup. A dir with no readable manifest is measured
|
|
302
|
+
but not cached (there is nowhere to write it).
|
|
303
|
+
"""
|
|
304
|
+
manifest = read_manifest(version_dir)
|
|
305
|
+
if manifest is not None and manifest.size_bytes is not None:
|
|
306
|
+
return manifest.size_bytes
|
|
307
|
+
|
|
308
|
+
total = 0
|
|
309
|
+
for path in version_dir.rglob("*"):
|
|
310
|
+
try:
|
|
311
|
+
if path.is_file():
|
|
312
|
+
total += path.stat().st_size
|
|
313
|
+
except OSError:
|
|
314
|
+
# A file that vanished mid-walk is not counted; the size is an
|
|
315
|
+
# estimate for the room check, not a ledger.
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
if manifest is not None:
|
|
319
|
+
write_json_atomic(
|
|
320
|
+
version_dir / MANIFEST_NAME,
|
|
321
|
+
replace(manifest, size_bytes=total).to_dict(),
|
|
322
|
+
)
|
|
323
|
+
return total
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def largest_version_bytes() -> int:
|
|
327
|
+
"""The size of the largest existing version dir, or 0 when the tree is empty.
|
|
328
|
+
|
|
329
|
+
This is the best available estimate of "what one more version dir costs":
|
|
330
|
+
the versions on this host are the same package and dependency closure, so the
|
|
331
|
+
largest already-present dir is a floor for the candidate's footprint. Sizes
|
|
332
|
+
come from each dir's manifest cache (see ``version_dir_bytes``), so this is
|
|
333
|
+
manifest reads, not recursive walks.
|
|
334
|
+
"""
|
|
335
|
+
sizes = [version_dir_bytes(v) for v in list_versions()]
|
|
336
|
+
return max(sizes) if sizes else 0
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def candidate_bytes(min_bytes: int) -> int:
|
|
340
|
+
"""The bytes the guard should require free for one more version dir.
|
|
341
|
+
|
|
342
|
+
The design (NF-12, §6.2) is "insufficient free space to stage a FULL version
|
|
343
|
+
dir", so the requirement is the largest existing version dir — known from the
|
|
344
|
+
tree — floored by ``min_bytes`` as a backstop for a fresh or tiny tree.
|
|
345
|
+
"""
|
|
346
|
+
return max(largest_version_bytes(), min_bytes)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def has_room_for(min_bytes: int) -> bool:
|
|
350
|
+
"""True when the tree's filesystem has room for one more full version dir.
|
|
351
|
+
|
|
352
|
+
The free-space refusal (NF-12, mechanism §6.2): a node with no room to stage
|
|
353
|
+
a full version dir refuses the update rather than half-staging. The
|
|
354
|
+
requirement is ``candidate_bytes(min_bytes)`` — the largest existing version
|
|
355
|
+
dir, floored by ``min_bytes`` — not merely a fixed floor.
|
|
356
|
+
"""
|
|
357
|
+
return _free_bytes() >= candidate_bytes(min_bytes)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@dataclass(frozen=True)
|
|
361
|
+
class Preflight:
|
|
362
|
+
"""The supervisor's answer to "may this node take one more version dir?"."""
|
|
363
|
+
|
|
364
|
+
ok: bool
|
|
365
|
+
reason: str
|
|
366
|
+
free_bytes: int
|
|
367
|
+
required_bytes: int
|
|
368
|
+
versions: int
|
|
369
|
+
reclaimable: int
|
|
370
|
+
|
|
371
|
+
def to_dict(self) -> dict:
|
|
372
|
+
return {
|
|
373
|
+
"ok": self.ok,
|
|
374
|
+
"reason": self.reason,
|
|
375
|
+
"free_bytes": self.free_bytes,
|
|
376
|
+
"required_bytes": self.required_bytes,
|
|
377
|
+
"versions": self.versions,
|
|
378
|
+
"reclaimable": self.reclaimable,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def preflight(min_free_bytes: int) -> Preflight:
|
|
383
|
+
"""Whether the node can stage ONE more version dir, read-only.
|
|
384
|
+
|
|
385
|
+
The fleet path calls this **before** it creates anything, so a refusal is
|
|
386
|
+
early and the supervisor owns the policy — the same room requirement the STAGE
|
|
387
|
+
check uses (one policy, one place). Measures and decides; creates nothing.
|
|
388
|
+
|
|
389
|
+
The one refusal is **room**: the filesystem must have ``candidate_bytes`` free
|
|
390
|
+
for the candidate's contents (NF-12). Retention is **not** a refusal — a real
|
|
391
|
+
switch always brings the tree within its bound by reclaiming, so a small tree
|
|
392
|
+
has *more* headroom, not less. ``reclaimable`` is reported for observability;
|
|
393
|
+
it does not affect the verdict.
|
|
394
|
+
|
|
395
|
+
``reclaimable`` is the count of existing dirs that could be removed while
|
|
396
|
+
protecting the two versions a switch holds: the candidate (not yet created)
|
|
397
|
+
and whatever is current right now.
|
|
398
|
+
"""
|
|
399
|
+
free = _free_bytes()
|
|
400
|
+
required = candidate_bytes(min_free_bytes)
|
|
401
|
+
versions = list_versions()
|
|
402
|
+
current = selected()
|
|
403
|
+
# A real switch reclaims with `protect = {candidate, current-now}` — the
|
|
404
|
+
# candidate does not exist yet, so among EXISTING dirs only current is
|
|
405
|
+
# protected. (After the switch, today's current becomes the last good, which
|
|
406
|
+
# is why the two-versions-retained rule holds; but at preflight time the
|
|
407
|
+
# second protected slot is the not-yet-staged candidate, not another existing
|
|
408
|
+
# dir.) So every existing dir but current is reclaimable.
|
|
409
|
+
protected = {os.path.realpath(current)} if current is not None else set()
|
|
410
|
+
reclaimable = sum(1 for v in versions if os.path.realpath(v) not in protected)
|
|
411
|
+
# Retention is NOT a refusal here. A real switch always satisfies its bound:
|
|
412
|
+
# after the candidate lands, `reclaim` removes reclaimable dirs down to the
|
|
413
|
+
# configured keep, and since every existing dir but current is reclaimable the
|
|
414
|
+
# tree always reaches `min(N+1, keep) <= keep`. So retention cannot make an
|
|
415
|
+
# update fail; refusing on it would reject exactly the trees with the most
|
|
416
|
+
# headroom (a small tree under a larger bound). `reclaimable` is reported for
|
|
417
|
+
# observability, not as a gate.
|
|
418
|
+
if free < required:
|
|
419
|
+
return Preflight(
|
|
420
|
+
ok=False,
|
|
421
|
+
reason=(
|
|
422
|
+
f"insufficient free space: {free} < {required} bytes"
|
|
423
|
+
" (one version dir, floored at the minimum)"
|
|
424
|
+
),
|
|
425
|
+
free_bytes=free,
|
|
426
|
+
required_bytes=required,
|
|
427
|
+
versions=len(versions),
|
|
428
|
+
reclaimable=reclaimable,
|
|
429
|
+
)
|
|
430
|
+
return Preflight(
|
|
431
|
+
ok=True,
|
|
432
|
+
reason="room for one more version dir is available",
|
|
433
|
+
free_bytes=free,
|
|
434
|
+
required_bytes=required,
|
|
435
|
+
versions=len(versions),
|
|
436
|
+
reclaimable=reclaimable,
|
|
437
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: manta_node
|
|
3
|
-
Version: 0.6b5.
|
|
3
|
+
Version: 0.6b5.dev707
|
|
4
4
|
Summary: Node side software of the Manta platform. It is responsible for the communication with the Manta Core and the execution of the tasks.
|
|
5
5
|
Author-email: Hugo Miralles <hugo.miralles@manta-tech.io>, Matthew Thompson <matthew.thompson@manta-tech.io>
|
|
6
6
|
License-Expression: AGPL-3.0-or-later
|
|
@@ -6,7 +6,7 @@ manta_node/task_manager.py,sha256=xRE-yKdUW0FFR8vtX1jDwl1AxTbqVD_JS7QVR62GA00,38
|
|
|
6
6
|
manta_node/tasks.py,sha256=o837Hi-KFVJg9LF5iOtE8oD6UuTOfrEPWR-JOdhRGeY,6983
|
|
7
7
|
manta_node/utils.py,sha256=Hw4Iw2jYQYsfVLVJuBMt-_rMFBeml3nCDE2jiiXc8YM,1155
|
|
8
8
|
manta_node/cli/__init__.py,sha256=arzO3mXDJ-E2yeeytbZfN7mCswwxdQ43_QjyeL2E5yE,134
|
|
9
|
-
manta_node/cli/_version_generated.py,sha256=
|
|
9
|
+
manta_node/cli/_version_generated.py,sha256=oq-vrYUSYqZEeiOos3Fqzj9GAI7ShLAmLb-7xD8MTio,275
|
|
10
10
|
manta_node/cli/config_manager.py,sha256=BCju9zqzraviL3KrzRnB-vZQUY__Q5kuEqb7EdB8YdI,4409
|
|
11
11
|
manta_node/cli/main.py,sha256=dZa6zrypWBvpN8hd4qpxevvlyNFhxKINCg1537NJ36w,4318
|
|
12
12
|
manta_node/cli/version.py,sha256=3lrnIWpOosszBN-VsCBVLxDgpvCC7IB0aDIKx_3e7hI,3115
|
|
@@ -56,14 +56,14 @@ manta_node/supervisor/__init__.py,sha256=kgdoNce1tM4e7jvdxkdmnNJV1L92-NI2cRfYSdm
|
|
|
56
56
|
manta_node/supervisor/bounds.py,sha256=MlUfj0tYY-US348JsxYA-Ph5pSpgoOPlRdVTwmrPtZA,3181
|
|
57
57
|
manta_node/supervisor/executor.py,sha256=a0JafVoTmtbIu87nwUtj08KAC-Q-u0Ajdn_ptyu9yoc,6833
|
|
58
58
|
manta_node/supervisor/inflight.py,sha256=JOqwXYM928fpcQtYdoK7596eAg28KM70bh85cyQgX3Q,2663
|
|
59
|
-
manta_node/supervisor/lifecycle.py,sha256=
|
|
60
|
-
manta_node/supervisor/process.py,sha256=
|
|
59
|
+
manta_node/supervisor/lifecycle.py,sha256=SA-xJqlxwbLa37fvp0W_YbUZbrRz9vQJDpg6vvuokpI,10303
|
|
60
|
+
manta_node/supervisor/process.py,sha256=N5WvNCJnJR3c6I4uqBPPFqgEdCI9rFulHyGem9Shc-I,19155
|
|
61
61
|
manta_node/supervisor/records.py,sha256=fUlL1SfmWpGnCbJ9956cuy0H3a7-aEF-B-naqbJiYoU,6405
|
|
62
62
|
manta_node/supervisor/seam.py,sha256=HomMFTDbc0dZQ9VrfGVhQxjiU7E23-zwyjZnLsnq6dc,8031
|
|
63
|
-
manta_node/supervisor/switch.py,sha256=
|
|
64
|
-
manta_node-0.6b5.
|
|
65
|
-
manta_node-0.6b5.
|
|
66
|
-
manta_node-0.6b5.
|
|
67
|
-
manta_node-0.6b5.
|
|
68
|
-
manta_node-0.6b5.
|
|
69
|
-
manta_node-0.6b5.
|
|
63
|
+
manta_node/supervisor/switch.py,sha256=iWLukvuxynK3NU26iIk0qhkwrRPUyJuutV67QA3aLO8,16732
|
|
64
|
+
manta_node-0.6b5.dev707.dist-info/licenses/LICENSE,sha256=9mBgLhiB9goQPQjg9XQJ6FjgVxYw9Q8lyOZYkPGwF_c,34484
|
|
65
|
+
manta_node-0.6b5.dev707.dist-info/METADATA,sha256=uOmOaIVFbJllDFQMjCOr4zfNlYc-Qq9V4VaBk6uHYDg,4231
|
|
66
|
+
manta_node-0.6b5.dev707.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
67
|
+
manta_node-0.6b5.dev707.dist-info/entry_points.txt,sha256=uCwggRApalLEsYs1YxwTBN0-e9oMJAkMoBp01HaSdoY,110
|
|
68
|
+
manta_node-0.6b5.dev707.dist-info/top_level.txt,sha256=nbT5J5WETuXM9UPoQckl29uUOq8wMYdRb9QDx5kAFeo,11
|
|
69
|
+
manta_node-0.6b5.dev707.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|