seamless-workflow 0.2.0__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.
- seamless_workflow/__init__.py +40 -0
- seamless_workflow/adapters.py +50 -0
- seamless_workflow/assignment.py +8 -0
- seamless_workflow/attachments/__init__.py +3 -0
- seamless_workflow/attachments/api.py +217 -0
- seamless_workflow/attachments/fs/__init__.py +1 -0
- seamless_workflow/attachments/fs/native.py +63 -0
- seamless_workflow/attachments/fs/service.py +601 -0
- seamless_workflow/attachments/manual.py +65 -0
- seamless_workflow/attachments/output_widget.py +62 -0
- seamless_workflow/attachments/policy.py +41 -0
- seamless_workflow/attachments/runtime.py +594 -0
- seamless_workflow/attachments/session.py +93 -0
- seamless_workflow/attachments/share/__init__.py +2 -0
- seamless_workflow/attachments/share/api.py +300 -0
- seamless_workflow/attachments/share/archive.py +60 -0
- seamless_workflow/attachments/share/driver.py +110 -0
- seamless_workflow/attachments/share/mime.py +180 -0
- seamless_workflow/attachments/share/openapi.py +358 -0
- seamless_workflow/attachments/share/server.py +1660 -0
- seamless_workflow/attachments/share/spec.py +139 -0
- seamless_workflow/attachments/share/static/seamless-client.js +538 -0
- seamless_workflow/attachments/spec.py +42 -0
- seamless_workflow/attachments/traitlet.py +344 -0
- seamless_workflow/attachments/widget.py +229 -0
- seamless_workflow/builder_state.py +1107 -0
- seamless_workflow/configuration.py +103 -0
- seamless_workflow/context.py +3287 -0
- seamless_workflow/controller.py +160 -0
- seamless_workflow/diagnostics.py +69 -0
- seamless_workflow/endpoints.py +33 -0
- seamless_workflow/errors.py +69 -0
- seamless_workflow/graph.py +636 -0
- seamless_workflow/ingress.py +411 -0
- seamless_workflow/jupyter.py +49 -0
- seamless_workflow/lifecycle.py +26 -0
- seamless_workflow/reactive.py +386 -0
- seamless_workflow/runtime_api.py +227 -0
- seamless_workflow/scheduler.py +106 -0
- seamless_workflow/serialization.py +317 -0
- seamless_workflow/serve_graph.py +55 -0
- seamless_workflow/shareserver.py +36 -0
- seamless_workflow/sidework.py +102 -0
- seamless_workflow/views.py +62 -0
- seamless_workflow-0.2.0.dist-info/METADATA +157 -0
- seamless_workflow-0.2.0.dist-info/RECORD +49 -0
- seamless_workflow-0.2.0.dist-info/WHEEL +5 -0
- seamless_workflow-0.2.0.dist-info/entry_points.txt +2 -0
- seamless_workflow-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"""Filesystem transport. No graph or Context objects cross this boundary.
|
|
2
|
+
|
|
3
|
+
Each registration has a serial operation queue. The broker submits polls; the
|
|
4
|
+
bounded daemon pool performs all filesystem operations, including stat walks.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import gzip
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
import time
|
|
11
|
+
import secrets
|
|
12
|
+
from collections import deque
|
|
13
|
+
from concurrent.futures import Future
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from queue import Queue
|
|
17
|
+
from threading import RLock, Thread, Event
|
|
18
|
+
|
|
19
|
+
from seamless import Buffer, Checksum
|
|
20
|
+
from seamless.checksum.canonical import canon_T, DIRECTORY_CELLTYPES
|
|
21
|
+
from ..policy import ABSENT, INVALID
|
|
22
|
+
from ..session import Observation, DeliveryAck, MountLease
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FileSystem:
|
|
26
|
+
"""Injectable filesystem boundary for deterministic race/failure tests."""
|
|
27
|
+
stat = staticmethod(os.stat)
|
|
28
|
+
read = staticmethod(lambda path: Path(path).read_bytes())
|
|
29
|
+
replace = staticmethod(os.replace)
|
|
30
|
+
unlink = staticmethod(os.unlink)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(eq=False)
|
|
34
|
+
class Registration:
|
|
35
|
+
path: str
|
|
36
|
+
session_id: str
|
|
37
|
+
spec: object
|
|
38
|
+
celltype: str
|
|
39
|
+
sink: object
|
|
40
|
+
active: bool = False
|
|
41
|
+
closed: bool = False
|
|
42
|
+
ws: int = 0
|
|
43
|
+
baseline: object = None
|
|
44
|
+
lock: object = field(default_factory=RLock)
|
|
45
|
+
queue: object = field(default_factory=deque)
|
|
46
|
+
busy: bool = False
|
|
47
|
+
poll_queued: bool = False
|
|
48
|
+
closing: bool = False
|
|
49
|
+
replaces: tuple = ()
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def directory(self): return self.celltype in DIRECTORY_CELLTYPES
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FileSystemService:
|
|
56
|
+
poll_interval = .2
|
|
57
|
+
racy_window = 2.
|
|
58
|
+
delivery_timeout = 60.
|
|
59
|
+
max_file_size = 1024**3
|
|
60
|
+
max_files = 100_000
|
|
61
|
+
max_tree_size = 10 * 1024**3
|
|
62
|
+
scan_timeout = 60.
|
|
63
|
+
|
|
64
|
+
def __init__(self, *, filesystem=None, workers=4, native=False):
|
|
65
|
+
self.fs = filesystem or FileSystem()
|
|
66
|
+
from .native import NativeNotifications
|
|
67
|
+
self.native = NativeNotifications() if native else None
|
|
68
|
+
self.lock = RLock()
|
|
69
|
+
self.registrations = set()
|
|
70
|
+
self._directory_source_owners = {}
|
|
71
|
+
self.read_locks = {}
|
|
72
|
+
self.work = Queue()
|
|
73
|
+
self.stopping = Event()
|
|
74
|
+
self.workers = [Thread(target=self._worker, name=f'Mount-IO-{n}', daemon=True) for n in range(workers)]
|
|
75
|
+
for worker in self.workers: worker.start()
|
|
76
|
+
self.broker = Thread(target=self._broker, name='Mount-broker', daemon=True)
|
|
77
|
+
self.broker.start()
|
|
78
|
+
|
|
79
|
+
def _worker(self):
|
|
80
|
+
while True:
|
|
81
|
+
task = self.work.get()
|
|
82
|
+
if task is None: return
|
|
83
|
+
reg, function, future = task
|
|
84
|
+
try:
|
|
85
|
+
with reg.io_lock:
|
|
86
|
+
result = function()
|
|
87
|
+
except BaseException as exc:
|
|
88
|
+
future.set_exception(exc)
|
|
89
|
+
else:
|
|
90
|
+
future.set_result(result)
|
|
91
|
+
finally:
|
|
92
|
+
with reg.lock:
|
|
93
|
+
if reg.queue:
|
|
94
|
+
self.work.put((reg, *reg.queue.popleft()))
|
|
95
|
+
else: reg.busy = False
|
|
96
|
+
|
|
97
|
+
def _submit(self, reg, function):
|
|
98
|
+
future = Future()
|
|
99
|
+
with reg.lock:
|
|
100
|
+
if reg.busy: reg.queue.append((function, future))
|
|
101
|
+
else:
|
|
102
|
+
reg.busy = True
|
|
103
|
+
self.work.put((reg, function, future))
|
|
104
|
+
return future
|
|
105
|
+
|
|
106
|
+
def reserve(self, spec, celltype, session_id, sink, *, replaces=()):
|
|
107
|
+
path = os.path.realpath(os.path.abspath(spec.path))
|
|
108
|
+
reg = Registration(path, session_id, spec, celltype, sink, replaces=tuple(replaces))
|
|
109
|
+
reg.service = self
|
|
110
|
+
with self.lock:
|
|
111
|
+
reg.io_lock = self.read_locks.setdefault(path, RLock())
|
|
112
|
+
if self.stopping.is_set(): raise RuntimeError('Filesystem service closed')
|
|
113
|
+
for other in self.registrations:
|
|
114
|
+
if other.closed or other.closing or other in replaces: continue
|
|
115
|
+
overlap = path == other.path or path.startswith(other.path + os.sep) or other.path.startswith(path + os.sep)
|
|
116
|
+
cleanup = reg.directory and not spec.persistent
|
|
117
|
+
other_cleanup = other.directory and not other.spec.persistent
|
|
118
|
+
if overlap and ('w' in spec.mode or 'w' in other.spec.mode or cleanup or other_cleanup):
|
|
119
|
+
raise ValueError(f'Mount path overlaps existing registration: {path!r}, {other.path!r}')
|
|
120
|
+
self.registrations.add(reg)
|
|
121
|
+
return reg
|
|
122
|
+
|
|
123
|
+
def activate(self, reg):
|
|
124
|
+
reg.active = True
|
|
125
|
+
|
|
126
|
+
def initial_read(self, reg):
|
|
127
|
+
def initial():
|
|
128
|
+
reg.ws += 1
|
|
129
|
+
observation = self._read(reg, reg.ws, initial=True)
|
|
130
|
+
reg.baseline = observation.fingerprint
|
|
131
|
+
return observation
|
|
132
|
+
return self._submit(reg, initial)
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _temporary(name):
|
|
136
|
+
return name.startswith('.') and '.seamless-' in name and name.endswith('.tmp')
|
|
137
|
+
|
|
138
|
+
def _stat(self, path):
|
|
139
|
+
try:
|
|
140
|
+
s = self.fs.stat(path)
|
|
141
|
+
return (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns, s.st_ctime_ns)
|
|
142
|
+
except FileNotFoundError: return None
|
|
143
|
+
|
|
144
|
+
def _fingerprint(self, reg):
|
|
145
|
+
root = self._stat(reg.path)
|
|
146
|
+
if root is None or not reg.directory: return root
|
|
147
|
+
if not stat.S_ISDIR(self.fs.stat(reg.path).st_mode): raise NotADirectoryError(reg.path)
|
|
148
|
+
start, entries = time.monotonic(), []
|
|
149
|
+
for parent, dirs, files in os.walk(reg.path, followlinks=False):
|
|
150
|
+
dirs.sort()
|
|
151
|
+
for name in sorted(files):
|
|
152
|
+
if self._temporary(name): continue
|
|
153
|
+
path = os.path.join(parent, name)
|
|
154
|
+
if os.path.islink(path):
|
|
155
|
+
raise ValueError('Directory mounts do not follow leaf symlinks')
|
|
156
|
+
entries.append((os.path.relpath(path, reg.path), self._stat(path)))
|
|
157
|
+
if len(entries) > self.max_files or time.monotonic() - start > self.scan_timeout:
|
|
158
|
+
raise ValueError('Directory scan limit exceeded')
|
|
159
|
+
return root, tuple(entries)
|
|
160
|
+
|
|
161
|
+
def _decode(self, path, data):
|
|
162
|
+
if not data: return b""
|
|
163
|
+
if path.endswith('.gz'):
|
|
164
|
+
import io
|
|
165
|
+
with gzip.GzipFile(fileobj=io.BytesIO(data)) as stream:
|
|
166
|
+
return stream.read(self.max_file_size + 1)
|
|
167
|
+
if path.endswith('.zst'):
|
|
168
|
+
import zstandard
|
|
169
|
+
with zstandard.ZstdDecompressor().stream_reader(data) as stream:
|
|
170
|
+
return stream.read(self.max_file_size + 1)
|
|
171
|
+
return data
|
|
172
|
+
|
|
173
|
+
def _encode(self, path, data):
|
|
174
|
+
if not data: return b""
|
|
175
|
+
if path.endswith('.gz'): return gzip.compress(data, compresslevel=6, mtime=0)
|
|
176
|
+
if path.endswith('.zst'):
|
|
177
|
+
import zstandard
|
|
178
|
+
return zstandard.ZstdCompressor(level=3).compress(data)
|
|
179
|
+
return data
|
|
180
|
+
|
|
181
|
+
def _read_bytes(self, path):
|
|
182
|
+
if self.fs.stat(path).st_size > self.max_file_size: raise ValueError('File size limit exceeded')
|
|
183
|
+
content = self._decode(path, self.fs.read(path))
|
|
184
|
+
if len(content) > self.max_file_size: raise ValueError('Decompressed file size limit exceeded')
|
|
185
|
+
return content
|
|
186
|
+
|
|
187
|
+
def _read(self, reg, ws, *, initial=False):
|
|
188
|
+
leases = []
|
|
189
|
+
fingerprint = None
|
|
190
|
+
try:
|
|
191
|
+
for attempt in range(5):
|
|
192
|
+
for lease in leases: lease._release_refholds()
|
|
193
|
+
leases = []
|
|
194
|
+
fingerprint = self._fingerprint(reg)
|
|
195
|
+
if fingerprint is None:
|
|
196
|
+
return Observation(reg.session_id, ws, None, ABSENT, no_value=True)
|
|
197
|
+
no_value = False
|
|
198
|
+
needs_canonical_write = False
|
|
199
|
+
if reg.directory:
|
|
200
|
+
index, size = {}, 0
|
|
201
|
+
started = time.monotonic()
|
|
202
|
+
for relative, _ in fingerprint[1]:
|
|
203
|
+
content = self._read_bytes(os.path.join(reg.path, relative))
|
|
204
|
+
size += len(content)
|
|
205
|
+
if size > self.max_tree_size or time.monotonic() - started > self.scan_timeout:
|
|
206
|
+
raise ValueError('Directory content limit exceeded')
|
|
207
|
+
key = relative[:-3] if relative.endswith('.gz') else relative[:-4] if relative.endswith('.zst') else relative
|
|
208
|
+
if key in index: raise ValueError(f'Duplicate decompressed directory path: {key}')
|
|
209
|
+
buf = Buffer(content)
|
|
210
|
+
cs = buf.get_checksum()
|
|
211
|
+
buf.tempref()
|
|
212
|
+
leases.append(MountLease(cs, f'mount:{reg.session_id}:leaf'))
|
|
213
|
+
index[key] = cs.hex()
|
|
214
|
+
no_value = initial and not index
|
|
215
|
+
if no_value:
|
|
216
|
+
from seamless.checksum.null import NULL_BUFFER
|
|
217
|
+
buf = Buffer(NULL_BUFFER)
|
|
218
|
+
else:
|
|
219
|
+
buf = Buffer(index, 'plain')
|
|
220
|
+
else:
|
|
221
|
+
content = self._read_bytes(reg.path)
|
|
222
|
+
no_value = not content
|
|
223
|
+
if no_value:
|
|
224
|
+
from seamless.checksum.null import NULL_BUFFER
|
|
225
|
+
buf = Buffer(NULL_BUFFER)
|
|
226
|
+
else:
|
|
227
|
+
canonical = canon_T(content, reg.celltype)
|
|
228
|
+
needs_canonical_write = canonical != content
|
|
229
|
+
buf = Buffer(canonical)
|
|
230
|
+
after = self._fingerprint(reg)
|
|
231
|
+
if fingerprint == after: break
|
|
232
|
+
time.sleep(.01 * (attempt + 1))
|
|
233
|
+
else: raise OSError('File changed throughout stable read')
|
|
234
|
+
cs = buf.get_checksum()
|
|
235
|
+
buf.tempref()
|
|
236
|
+
leases.insert(0, MountLease(cs, f'mount:{reg.session_id}:observation'))
|
|
237
|
+
return Observation(reg.session_id, ws, fingerprint, cs.hex(), buf, tuple(leases),
|
|
238
|
+
no_value=no_value, needs_canonical_write=needs_canonical_write)
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
for lease in leases: lease._release_refholds()
|
|
241
|
+
return Observation(reg.session_id, ws, fingerprint, INVALID, reason=f'{type(exc).__name__}: {exc}')
|
|
242
|
+
|
|
243
|
+
def _send(self, reg, operation, payload):
|
|
244
|
+
if reg.closed and operation == '_mount_observed':
|
|
245
|
+
payload.release()
|
|
246
|
+
return
|
|
247
|
+
reg.sink(operation, payload)
|
|
248
|
+
|
|
249
|
+
def _poll(self, reg, *, force=False):
|
|
250
|
+
with reg.io_lock: return self._poll_locked(reg, force=force)
|
|
251
|
+
|
|
252
|
+
def _poll_locked(self, reg, *, force=False):
|
|
253
|
+
if reg.closed or not reg.active: return
|
|
254
|
+
try:
|
|
255
|
+
fingerprint = self._fingerprint(reg)
|
|
256
|
+
root = fingerprint[0] if reg.directory and fingerprint else fingerprint
|
|
257
|
+
stamps = [root] if not reg.directory or not fingerprint else [root, *(fp for _, fp in fingerprint[1])]
|
|
258
|
+
racy = any(fp is not None and abs(time.time() - fp[3] / 1e9) <= self.racy_window for fp in stamps)
|
|
259
|
+
except Exception:
|
|
260
|
+
fingerprint, racy = INVALID, True
|
|
261
|
+
if force or fingerprint != reg.baseline or racy:
|
|
262
|
+
reg.ws += 1
|
|
263
|
+
reg.baseline = fingerprint
|
|
264
|
+
observation = self._read(reg, reg.ws)
|
|
265
|
+
if reg.spec.mode == 'r':
|
|
266
|
+
with self.lock:
|
|
267
|
+
peers = tuple(r for r in self.registrations if r is not reg and r.active and not r.closed
|
|
268
|
+
and r.spec.mode == 'r' and r.path == reg.path and r.celltype == reg.celltype)
|
|
269
|
+
for peer in peers:
|
|
270
|
+
with peer.lock:
|
|
271
|
+
peer.ws += 1
|
|
272
|
+
peer.baseline = fingerprint
|
|
273
|
+
leases = tuple(MountLease(lease.checksum, f'mount:{peer.session_id}:shared-read')
|
|
274
|
+
for lease in observation.leases)
|
|
275
|
+
shared = Observation(peer.session_id, peer.ws, observation.fingerprint,
|
|
276
|
+
observation.checksum, observation.buffer, leases, observation.reason)
|
|
277
|
+
self._send(peer, '_mount_observed', shared)
|
|
278
|
+
self._send(reg, '_mount_observed', observation)
|
|
279
|
+
|
|
280
|
+
def poll(self, reg, *, force=False):
|
|
281
|
+
return self._submit(reg, lambda: self._poll(reg, force=force))
|
|
282
|
+
|
|
283
|
+
def request_cut(self, reg, cut_id):
|
|
284
|
+
def cut():
|
|
285
|
+
self._poll(reg, force=True)
|
|
286
|
+
self._send(reg, '_mount_cut', (reg.session_id, cut_id, reg.ws))
|
|
287
|
+
return self._submit(reg, cut)
|
|
288
|
+
|
|
289
|
+
def _broker(self):
|
|
290
|
+
while not self.stopping.wait(self.poll_interval):
|
|
291
|
+
with self.lock: registrations = tuple(self.registrations)
|
|
292
|
+
changed = self.native.poll(registrations) if self.native else set()
|
|
293
|
+
shared = set()
|
|
294
|
+
for reg in registrations:
|
|
295
|
+
if reg.spec.mode == "r":
|
|
296
|
+
key = (reg.path, reg.celltype)
|
|
297
|
+
if key in shared: continue
|
|
298
|
+
if reg.active and not reg.closed: shared.add(key)
|
|
299
|
+
with reg.lock:
|
|
300
|
+
if reg.closed or not reg.active or reg.poll_queued: continue
|
|
301
|
+
reg.poll_queued = True
|
|
302
|
+
def poll(reg=reg, force=reg.path in changed):
|
|
303
|
+
try:
|
|
304
|
+
self._poll(reg, force=force)
|
|
305
|
+
self._send(reg, '_mount_tick', reg.session_id)
|
|
306
|
+
finally: reg.poll_queued = False
|
|
307
|
+
self._submit(reg, poll)
|
|
308
|
+
|
|
309
|
+
def _atomic_write(self, path, content, expected):
|
|
310
|
+
parent, name = os.path.split(path)
|
|
311
|
+
temporary = os.path.join(parent, f'.{name}.seamless-{secrets.token_hex(8)}.tmp')
|
|
312
|
+
try:
|
|
313
|
+
with open(temporary, 'xb') as handle:
|
|
314
|
+
handle.write(self._encode(path, content))
|
|
315
|
+
if expected is not None:
|
|
316
|
+
os.chmod(temporary, stat.S_IMODE(self.fs.stat(path).st_mode))
|
|
317
|
+
if self._stat(path) != expected: return False
|
|
318
|
+
self.fs.replace(temporary, path)
|
|
319
|
+
return True
|
|
320
|
+
finally:
|
|
321
|
+
try: self.fs.unlink(temporary)
|
|
322
|
+
except FileNotFoundError: pass
|
|
323
|
+
|
|
324
|
+
def _resolve(self, checksum):
|
|
325
|
+
import asyncio
|
|
326
|
+
async def resolve():
|
|
327
|
+
return await asyncio.wait_for(checksum.resolution(), self.delivery_timeout)
|
|
328
|
+
return asyncio.run(resolve())
|
|
329
|
+
|
|
330
|
+
def _directory_leaf_loader(self, reg, relative_paths, checksum):
|
|
331
|
+
from seamless.checksum.calculate_checksum import calculate_checksum
|
|
332
|
+
|
|
333
|
+
def load():
|
|
334
|
+
if reg.closing or reg.closed:
|
|
335
|
+
return None
|
|
336
|
+
for relative in relative_paths:
|
|
337
|
+
try:
|
|
338
|
+
path = os.path.join(reg.path, relative)
|
|
339
|
+
absolute_path = os.path.abspath(path)
|
|
340
|
+
real_path = os.path.realpath(path)
|
|
341
|
+
if (os.path.islink(path) or real_path != absolute_path
|
|
342
|
+
or os.path.commonpath((real_path, reg.path)) != reg.path):
|
|
343
|
+
continue
|
|
344
|
+
content = self._read_bytes(path)
|
|
345
|
+
except (OSError, ValueError):
|
|
346
|
+
continue
|
|
347
|
+
if calculate_checksum(content) == checksum.hex():
|
|
348
|
+
return Buffer(content, checksum=checksum)
|
|
349
|
+
return None
|
|
350
|
+
return load
|
|
351
|
+
|
|
352
|
+
def _directory_leaf_metadata(self, reg, relative_paths):
|
|
353
|
+
if reg.closing or reg.closed:
|
|
354
|
+
return ()
|
|
355
|
+
result = []
|
|
356
|
+
for relative in relative_paths:
|
|
357
|
+
try:
|
|
358
|
+
path = os.path.join(reg.path, relative)
|
|
359
|
+
absolute_path = os.path.abspath(path)
|
|
360
|
+
real_path = os.path.realpath(path)
|
|
361
|
+
if (os.path.islink(path) or real_path != absolute_path
|
|
362
|
+
or os.path.commonpath((real_path, reg.path)) != reg.path):
|
|
363
|
+
continue
|
|
364
|
+
metadata = self.fs.stat(path)
|
|
365
|
+
except (OSError, ValueError):
|
|
366
|
+
continue
|
|
367
|
+
if stat.S_ISREG(metadata.st_mode):
|
|
368
|
+
result.append(metadata)
|
|
369
|
+
return tuple(result)
|
|
370
|
+
|
|
371
|
+
def _directory_buffer_source(self, reg, leaves):
|
|
372
|
+
from seamless.caching.buffer_source import BufferSource
|
|
373
|
+
|
|
374
|
+
loaders = {}
|
|
375
|
+
for checksum, paths in leaves.items():
|
|
376
|
+
checksum = Checksum(checksum)
|
|
377
|
+
paths = tuple(paths)
|
|
378
|
+
loaders[checksum] = (
|
|
379
|
+
self._directory_leaf_loader(reg, paths, checksum),
|
|
380
|
+
paths,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def get_buffer(checksum):
|
|
384
|
+
entry = loaders.get(Checksum(checksum))
|
|
385
|
+
return None if entry is None else entry[0]()
|
|
386
|
+
|
|
387
|
+
def metadata(checksum):
|
|
388
|
+
entry = loaders.get(Checksum(checksum))
|
|
389
|
+
if entry is None:
|
|
390
|
+
return ()
|
|
391
|
+
return self._directory_leaf_metadata(reg, entry[1])
|
|
392
|
+
|
|
393
|
+
def has_buffer(checksum):
|
|
394
|
+
return bool(metadata(checksum))
|
|
395
|
+
|
|
396
|
+
def get_buffer_length(checksum):
|
|
397
|
+
entry = loaders.get(Checksum(checksum))
|
|
398
|
+
if entry is None:
|
|
399
|
+
return None
|
|
400
|
+
# Mount paths can be transparently compressed. Their physical
|
|
401
|
+
# size is not the size of the checksum-addressed buffer.
|
|
402
|
+
for relative in entry[1]:
|
|
403
|
+
if relative.endswith((".gz", ".zst")):
|
|
404
|
+
continue
|
|
405
|
+
files = self._directory_leaf_metadata(reg, (relative,))
|
|
406
|
+
if files:
|
|
407
|
+
return files[0].st_size
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
return BufferSource(
|
|
411
|
+
get_buffer=get_buffer,
|
|
412
|
+
has_buffer=has_buffer,
|
|
413
|
+
get_buffer_length=get_buffer_length,
|
|
414
|
+
returns_verified_buffer=True,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def _replace_directory_buffer_sources(self, reg, leaves):
|
|
418
|
+
from seamless.caching.buffer_source import replace_buffer_sources
|
|
419
|
+
|
|
420
|
+
source = self._directory_buffer_source(reg, leaves) if leaves else ()
|
|
421
|
+
replace_buffer_sources(reg, source)
|
|
422
|
+
|
|
423
|
+
def _unregister_directory_buffer_sources(self, reg):
|
|
424
|
+
from seamless.caching.buffer_source import unregister_buffer_sources
|
|
425
|
+
|
|
426
|
+
unregister_buffer_sources(reg)
|
|
427
|
+
owner = self._directory_source_owners.pop(reg, None)
|
|
428
|
+
if owner is not None:
|
|
429
|
+
unregister_buffer_sources(owner)
|
|
430
|
+
|
|
431
|
+
def _stage_directory_buffer_sources(self, reg, leaves):
|
|
432
|
+
from seamless.caching.buffer_source import replace_buffer_sources
|
|
433
|
+
|
|
434
|
+
owner = self._directory_source_owners.setdefault(reg, object())
|
|
435
|
+
source = self._directory_buffer_source(reg, leaves)
|
|
436
|
+
replace_buffer_sources(owner, source)
|
|
437
|
+
return owner
|
|
438
|
+
|
|
439
|
+
def _clear_staged_directory_buffer_sources(self, reg):
|
|
440
|
+
from seamless.caching.buffer_source import unregister_buffer_sources
|
|
441
|
+
|
|
442
|
+
owner = self._directory_source_owners.pop(reg, None)
|
|
443
|
+
if owner is not None:
|
|
444
|
+
unregister_buffer_sources(owner)
|
|
445
|
+
|
|
446
|
+
def _write_directory(self, reg, delivery, content):
|
|
447
|
+
import json
|
|
448
|
+
index = json.loads(content)
|
|
449
|
+
if index is None:
|
|
450
|
+
if self._fingerprint(reg) != delivery.expected_fingerprint: return False
|
|
451
|
+
self._replace_directory_buffer_sources(reg, {})
|
|
452
|
+
self._clear_staged_directory_buffer_sources(reg)
|
|
453
|
+
return True
|
|
454
|
+
if not isinstance(index, dict): raise ValueError('Directory index must be a mapping')
|
|
455
|
+
if len(index) > self.max_files: raise ValueError('Directory file-count limit exceeded')
|
|
456
|
+
for name in index:
|
|
457
|
+
if (not isinstance(name, str) or not name or os.path.isabs(name)
|
|
458
|
+
or any(part in {'', '.', '..'} for part in name.split('/'))):
|
|
459
|
+
raise ValueError(f'Unsafe directory path: {name!r}')
|
|
460
|
+
if self._fingerprint(reg) != delivery.expected_fingerprint: return False
|
|
461
|
+
physical_names = {}
|
|
462
|
+
if delivery.expected_fingerprint is not None:
|
|
463
|
+
for relative, _ in delivery.expected_fingerprint[1]:
|
|
464
|
+
key = relative[:-3] if relative.endswith('.gz') else relative[:-4] if relative.endswith('.zst') else relative
|
|
465
|
+
if key in physical_names: raise ValueError(f'Duplicate decompressed directory path: {key}')
|
|
466
|
+
physical_names[key] = relative
|
|
467
|
+
leaf_sources = {}
|
|
468
|
+
for name, checksum in sorted(index.items()):
|
|
469
|
+
default_name = name + ('.gz' if name.endswith('.gz') else '.zst' if name.endswith('.zst') else '')
|
|
470
|
+
path = os.path.join(reg.path, physical_names.get(name, default_name))
|
|
471
|
+
if os.path.commonpath((os.path.realpath(path), reg.path)) != reg.path:
|
|
472
|
+
raise ValueError('Directory path escapes mount through a symlink')
|
|
473
|
+
leaf_sources.setdefault(checksum, []).append(os.path.relpath(path, reg.path))
|
|
474
|
+
|
|
475
|
+
os.makedirs(reg.path, exist_ok=True)
|
|
476
|
+
# Keep the previous source owner available while writing a replacement
|
|
477
|
+
# index. Staged paths become usable as each leaf is written; the source
|
|
478
|
+
# callbacks validate content before returning it.
|
|
479
|
+
self._stage_directory_buffer_sources(reg, leaf_sources)
|
|
480
|
+
total, started = 0, time.monotonic()
|
|
481
|
+
for name, checksum in sorted(index.items()):
|
|
482
|
+
if reg.closed: raise RuntimeError('Directory mount closed during write')
|
|
483
|
+
default_name = name + ('.gz' if name.endswith('.gz') else '.zst' if name.endswith('.zst') else '')
|
|
484
|
+
path = os.path.join(reg.path, physical_names.get(name, default_name))
|
|
485
|
+
absolute_path = os.path.abspath(path)
|
|
486
|
+
real_path = os.path.realpath(path)
|
|
487
|
+
if (os.path.islink(path) or real_path != absolute_path
|
|
488
|
+
or os.path.commonpath((real_path, reg.path)) != reg.path):
|
|
489
|
+
raise ValueError('Directory path escapes mount through a symlink')
|
|
490
|
+
buf = self._resolve(Checksum(checksum))
|
|
491
|
+
data = buf.content if hasattr(buf, 'content') else bytes(buf)
|
|
492
|
+
total += len(data)
|
|
493
|
+
if total > self.max_tree_size or time.monotonic() - started > self.scan_timeout:
|
|
494
|
+
raise ValueError('Directory delivery limit exceeded')
|
|
495
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
496
|
+
expected = self._stat(path)
|
|
497
|
+
if expected is None or self._read_bytes(path) != data:
|
|
498
|
+
if not self._atomic_write(path, data, expected):
|
|
499
|
+
raise OSError(f'Directory leaf changed: {name}')
|
|
500
|
+
if reg.spec.mode == 'w' or reg.spec.authority == 'cell':
|
|
501
|
+
for parent, dirs, files in os.walk(reg.path, topdown=False):
|
|
502
|
+
for name in files:
|
|
503
|
+
path = os.path.join(parent, name)
|
|
504
|
+
if self._temporary(name): continue
|
|
505
|
+
relative = os.path.relpath(path, reg.path)
|
|
506
|
+
key = relative[:-3] if relative.endswith('.gz') else relative[:-4] if relative.endswith('.zst') else relative
|
|
507
|
+
if key not in index: self.fs.unlink(path)
|
|
508
|
+
for name in dirs:
|
|
509
|
+
try: os.rmdir(os.path.join(parent, name))
|
|
510
|
+
except OSError: pass
|
|
511
|
+
self._replace_directory_buffer_sources(reg, leaf_sources)
|
|
512
|
+
self._clear_staged_directory_buffer_sources(reg)
|
|
513
|
+
return True
|
|
514
|
+
|
|
515
|
+
def deliver(self, reg, delivery):
|
|
516
|
+
# The controller may release its delivery claim on unmount while this
|
|
517
|
+
# queued operation is still resolving. Retain a separate worker claim.
|
|
518
|
+
transport_lease = MountLease(delivery.lease.checksum, f'mount:{reg.session_id}:io:{delivery.seq}')
|
|
519
|
+
def write():
|
|
520
|
+
try:
|
|
521
|
+
if reg.closed: raise RuntimeError('Mount was unregistered')
|
|
522
|
+
buf = self._resolve(transport_lease.checksum)
|
|
523
|
+
content = buf.content if hasattr(buf, 'content') else bytes(buf)
|
|
524
|
+
disk_checksum = delivery.checksum
|
|
525
|
+
if not reg.directory:
|
|
526
|
+
content = canon_T(content, reg.celltype)
|
|
527
|
+
from seamless import Buffer
|
|
528
|
+
disk_checksum = Buffer(content).get_checksum().hex()
|
|
529
|
+
from seamless.checksum.null import NULL_BUFFER
|
|
530
|
+
if not reg.directory and content == NULL_BUFFER:
|
|
531
|
+
content = b''
|
|
532
|
+
written = (self._write_directory(reg, delivery, content) if reg.directory else
|
|
533
|
+
self._atomic_write(reg.path, content, delivery.expected_fingerprint))
|
|
534
|
+
if not written:
|
|
535
|
+
ack = DeliveryAck(reg.session_id, delivery.seq, reg.ws, 'conflict')
|
|
536
|
+
else:
|
|
537
|
+
reg.ws += 1
|
|
538
|
+
reg.baseline = self._fingerprint(reg)
|
|
539
|
+
ack = DeliveryAck(reg.session_id, delivery.seq, reg.ws, 'written', disk_checksum, reg.baseline)
|
|
540
|
+
except Exception as exc:
|
|
541
|
+
ack = DeliveryAck(reg.session_id, delivery.seq, reg.ws, 'error', reason=f'{type(exc).__name__}: {exc}')
|
|
542
|
+
transport_lease._release_refholds()
|
|
543
|
+
self._send(reg, '_mount_delivered', ack)
|
|
544
|
+
if ack.outcome == 'conflict': self._poll(reg, force=True)
|
|
545
|
+
return self._submit(reg, write)
|
|
546
|
+
|
|
547
|
+
def unregister(self, reg, *, delete=False, expected=None):
|
|
548
|
+
reg.active = False
|
|
549
|
+
reg.closing = True
|
|
550
|
+
self._unregister_directory_buffer_sources(reg)
|
|
551
|
+
def cleanup():
|
|
552
|
+
try:
|
|
553
|
+
with self.lock:
|
|
554
|
+
remounted = any(r is not reg and not r.closed and not r.closing and r.path == reg.path
|
|
555
|
+
for r in self.registrations)
|
|
556
|
+
if delete and not remounted and self._fingerprint(reg) == expected and expected is not None:
|
|
557
|
+
if reg.directory:
|
|
558
|
+
import shutil
|
|
559
|
+
shutil.rmtree(reg.path)
|
|
560
|
+
else: self.fs.unlink(reg.path)
|
|
561
|
+
finally:
|
|
562
|
+
reg.closed = True
|
|
563
|
+
try:
|
|
564
|
+
# An in-flight delivery may have staged new sources after
|
|
565
|
+
# unregister() started. The cleanup is serialized behind it,
|
|
566
|
+
# so remove its final source map here as well.
|
|
567
|
+
self._unregister_directory_buffer_sources(reg)
|
|
568
|
+
finally:
|
|
569
|
+
with self.lock:
|
|
570
|
+
self.registrations.discard(reg)
|
|
571
|
+
if not any(r.path == reg.path for r in self.registrations):
|
|
572
|
+
self.read_locks.pop(reg.path, None)
|
|
573
|
+
return self._submit(reg, cleanup)
|
|
574
|
+
|
|
575
|
+
def close(self, timeout=60):
|
|
576
|
+
self.stopping.set()
|
|
577
|
+
self.broker.join(timeout)
|
|
578
|
+
if self.native is not None: self.native.close()
|
|
579
|
+
with self.lock: regs = tuple(self.registrations)
|
|
580
|
+
deadline = time.monotonic() + timeout
|
|
581
|
+
for reg in regs:
|
|
582
|
+
try: self.unregister(reg).result(max(0, deadline - time.monotonic()))
|
|
583
|
+
except Exception: pass
|
|
584
|
+
for _ in self.workers: self.work.put(None)
|
|
585
|
+
for worker in self.workers: worker.join(max(0, deadline - time.monotonic()))
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
_service = None
|
|
589
|
+
_service_lock = RLock()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def get_service():
|
|
593
|
+
global _service
|
|
594
|
+
with _service_lock:
|
|
595
|
+
if _service is None or _service.stopping.is_set(): _service = FileSystemService(native=os.environ.get("SEAMLESS_MOUNT_NATIVE") == "1")
|
|
596
|
+
return _service
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def close_service():
|
|
600
|
+
global _service
|
|
601
|
+
if _service is not None: _service.close()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Deterministic file-policy transport for tests; never serialized."""
|
|
2
|
+
from collections import deque
|
|
3
|
+
from concurrent.futures import Future
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
from seamless import Buffer
|
|
7
|
+
from .api import make_sink
|
|
8
|
+
from .spec import AttachmentSpec
|
|
9
|
+
from .session import Observation, DeliveryAck, MountLease
|
|
10
|
+
from .policy import ABSENT, INVALID
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ManualDriver:
|
|
14
|
+
delivery_timeout = 60
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self.deliveries = deque()
|
|
18
|
+
self.cuts = deque()
|
|
19
|
+
self.registration = None
|
|
20
|
+
|
|
21
|
+
def attach(self, cell, value=None, *, mode='rw', authority='file', rejected=None):
|
|
22
|
+
backend = cell._workflow_backend
|
|
23
|
+
ctx, path = backend.context, backend.node_path
|
|
24
|
+
spec = AttachmentSpec('manual-' + uuid4().hex, mode, authority, driver='manual')
|
|
25
|
+
celltype = ctx._controller.call('_mount_validate', path, spec, klass=4)
|
|
26
|
+
reg = self.registration = SimpleNamespace(session_id=uuid4().hex, celltype=celltype,
|
|
27
|
+
service=self, sink=make_sink(ctx._controller), active=False, closed=False, ws=0, spec=spec)
|
|
28
|
+
observation = self.observation(value, rejected=rejected)
|
|
29
|
+
ctx._controller.call('_mount_attach', path, spec, reg, observation, klass=2)
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def observation(self, value=None, *, ws=None, rejected=None):
|
|
33
|
+
reg = self.registration
|
|
34
|
+
if ws is None: reg.ws += 1; ws = reg.ws
|
|
35
|
+
if rejected is not None: return Observation(reg.session_id, ws, ws, INVALID, reason=rejected)
|
|
36
|
+
if value is None: return Observation(reg.session_id, ws, ws, ABSENT)
|
|
37
|
+
buf = Buffer(value, reg.celltype)
|
|
38
|
+
cs = buf.get_checksum(); buf.tempref()
|
|
39
|
+
return Observation(reg.session_id, ws, ws, cs.hex(), buf, (MountLease(cs, 'manual:observation'),))
|
|
40
|
+
|
|
41
|
+
def observe(self, value=None, **kwargs):
|
|
42
|
+
observation = self.observation(value, **kwargs)
|
|
43
|
+
self.registration.sink('_mount_observed', observation)
|
|
44
|
+
return observation
|
|
45
|
+
|
|
46
|
+
def activate(self, reg): reg.active = True
|
|
47
|
+
def deliver(self, reg, delivery): self.deliveries.append(delivery)
|
|
48
|
+
def request_cut(self, reg, cut_id): self.cuts.append(cut_id)
|
|
49
|
+
def poll(self, reg, *, force=False): pass
|
|
50
|
+
|
|
51
|
+
def ack(self, delivery=None, *, outcome='written', ws=None, reason='injected write failure'):
|
|
52
|
+
if delivery is None: delivery = self.deliveries.popleft()
|
|
53
|
+
reg = self.registration
|
|
54
|
+
if ws is None: reg.ws += 1; ws = reg.ws
|
|
55
|
+
reg.sink('_mount_delivered', DeliveryAck(reg.session_id, delivery.seq, ws, outcome,
|
|
56
|
+
delivery.checksum, ws, reason))
|
|
57
|
+
|
|
58
|
+
def cut(self):
|
|
59
|
+
reg = self.registration
|
|
60
|
+
reg.sink('_mount_cut', (reg.session_id, self.cuts.popleft(), reg.ws))
|
|
61
|
+
|
|
62
|
+
def unregister(self, reg, **kwargs):
|
|
63
|
+
reg.closed = True
|
|
64
|
+
future = Future(); future.set_result(None)
|
|
65
|
+
return future
|