leblob 0.4.0.dev1__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.
leblob/blobstore.py ADDED
@@ -0,0 +1,594 @@
1
+ """Blob files on one or more volumes: durable writes, range reads and deletion.
2
+
3
+ A blob's ID is random, so user-supplied keys never reach the filesystem.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import hashlib
10
+ import json
11
+ import logging
12
+ import os
13
+ import secrets
14
+ import sys
15
+ import time
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from contextlib import aclosing, suppress
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Self
21
+
22
+ from leblob import metrics
23
+ from leblob.model import Blob, BlobSlice
24
+ from leblob.s3 import errors
25
+ from leblob.s3.errors import S3Error
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import (
29
+ AsyncGenerator,
30
+ AsyncIterable,
31
+ Iterable,
32
+ Mapping,
33
+ Sequence,
34
+ )
35
+ from types import TracebackType
36
+
37
+ from leblob.model import VolumeRecord
38
+ from leblob.s3.payload import Hasher
39
+
40
+ BUFFER_BYTES = 1024 * 1024
41
+ TMP_MAX_AGE_SECONDS = 24 * 3600
42
+ VOLUME_FILE = "volume.json"
43
+
44
+ _SHARD_LENGTH = 3
45
+ _SHARD_COUNT = 16**_SHARD_LENGTH
46
+ _ALLOWED_IN_A_NEW_VOLUME = {"lost+found"} # A fresh ext4 filesystem has it.
47
+ _ID_NOT_ASSIGNED_YET = 0 # The metadata assigns it, once the files are in place.
48
+ _AVOID_SECONDS = 30
49
+ _RANDOM = secrets.SystemRandom()
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ class VolumeError(Exception):
55
+ """A volume that can't be used. The message says why."""
56
+
57
+
58
+ def existing_files(directory: Path) -> Iterable[tuple[str, os.stat_result]]:
59
+ """Yield the files in a directory that other workers are changing concurrently.
60
+
61
+ A file listed a moment ago may have been committed or collected since.
62
+ """
63
+ with os.scandir(directory) as entries:
64
+ for entry in entries:
65
+ try:
66
+ yield entry.path, entry.stat()
67
+ except FileNotFoundError:
68
+ continue
69
+
70
+
71
+ def fsync_directory(path: Path) -> None:
72
+ descriptor = os.open(path, os.O_RDONLY)
73
+ try:
74
+ os.fsync(descriptor)
75
+ finally:
76
+ os.close(descriptor)
77
+
78
+
79
+ def place(free_bytes: Mapping[int, int]) -> list[int]:
80
+ """Order volumes at random, weighted by free space, so that they fill evenly.
81
+
82
+ The first in the list are the ones to use, and the others take over if one fails.
83
+ A full volume keeps a tiny chance: if all are full, the write gets the real error.
84
+ """
85
+ remaining = dict(free_bytes)
86
+ order = []
87
+ while remaining:
88
+ weights = [free + 1 for free in remaining.values()]
89
+ chosen = _RANDOM.choices(list(remaining), weights=weights)[0]
90
+ order.append(chosen)
91
+ del remaining[chosen]
92
+ return order
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class Volume:
97
+ """A directory of immutable blob files, usually a whole disk."""
98
+
99
+ root: Path
100
+ volume_id: int
101
+
102
+ @property
103
+ def tmp_dir(self) -> Path:
104
+ return self.root / "tmp"
105
+
106
+ @property
107
+ def blobs_dir(self) -> Path:
108
+ return self.root / "blobs"
109
+
110
+ def initialize(self) -> None:
111
+ """Create every shard up front, so writes never create a directory."""
112
+ self.tmp_dir.mkdir(parents=True, exist_ok=True)
113
+ for number in range(_SHARD_COUNT):
114
+ shard = f"{number:0{_SHARD_LENGTH}x}"
115
+ (self.blobs_dir / shard).mkdir(parents=True, exist_ok=True)
116
+
117
+ def path(self, blob_id: str) -> Path:
118
+ return self.blobs_dir / blob_id[:_SHARD_LENGTH] / blob_id
119
+
120
+ def free_bytes(self) -> int:
121
+ stat = os.statvfs(self.root)
122
+ return stat.f_bavail * stat.f_frsize
123
+
124
+ def claim(self, identity: str) -> None:
125
+ """Durably write the file naming this volume, the last step of creating it."""
126
+ partial_file = self.root / f"{VOLUME_FILE}.partial"
127
+ with partial_file.open("w", encoding="utf-8") as file:
128
+ json.dump({"identity": identity}, file)
129
+ file.flush()
130
+ os.fsync(file.fileno())
131
+ partial_file.rename(self.root / VOLUME_FILE)
132
+ fsync_directory(self.root)
133
+
134
+ def delete(self, blob_id: str) -> None:
135
+ self.path(blob_id).unlink(missing_ok=True)
136
+
137
+ def identifies_itself(self) -> bool:
138
+ """False once the disk is unmounted, which empties the directory underneath."""
139
+ return (self.root / VOLUME_FILE).exists()
140
+
141
+ def copy_is_intact(self, blob: Blob) -> bool:
142
+ """Check a copy against its MD5, reading the disk and not the page cache."""
143
+ digest = hashlib.md5(usedforsecurity=False)
144
+ with self.path(blob.blob_id).open("rb") as file:
145
+ if sys.platform == "linux": # The deployment target.
146
+ os.posix_fadvise(file.fileno(), 0, 0, os.POSIX_FADV_DONTNEED)
147
+ while data := file.read(BUFFER_BYTES):
148
+ digest.update(data)
149
+ return digest.hexdigest() == blob.md5
150
+
151
+ def sweep_tmp(self, now: float) -> int:
152
+ """Remove temp files abandoned by a crash. Returns how many were removed."""
153
+ removed = 0
154
+ for path, stat in existing_files(self.tmp_dir):
155
+ if now - stat.st_mtime > TMP_MAX_AGE_SECONDS:
156
+ Path(path).unlink(missing_ok=True)
157
+ removed += 1
158
+ return removed
159
+
160
+ def scan(self) -> Iterable[tuple[str, int, float]]:
161
+ """Yield (blob ID, size, modification time) for every blob on disk."""
162
+ for shard in self.blobs_dir.iterdir():
163
+ if not shard.is_dir(): # Such as a `.DS_Store`: not ours, and no harm.
164
+ continue
165
+ for path, stat in existing_files(shard):
166
+ yield Path(path).name, stat.st_size, stat.st_mtime
167
+
168
+
169
+ @dataclass(slots=True)
170
+ class BlobStore:
171
+ """Every volume of a store that can be used now.
172
+
173
+ A volume that fails is tried last for a while, in this worker: otherwise a dead
174
+ disk would keep costing requests, until a person or repair deals with it.
175
+ """
176
+
177
+ volumes: Mapping[int, Volume]
178
+ copies: int = 1
179
+ draining: frozenset[int] = frozenset()
180
+ _avoid_until: dict[int, float] = field(
181
+ default_factory=dict, init=False, compare=False
182
+ )
183
+
184
+ def path(self, blob: Blob) -> Path:
185
+ return self.volumes[blob.volume_ids[0]].path(blob.blob_id)
186
+
187
+ def report_failure(self, volume_id: int, error: OSError) -> None:
188
+ logger.warning("Volume %s failed: %s", volume_id, error)
189
+ metrics.counters.add(metrics.VOLUME_FAILURES, volume=str(volume_id))
190
+ self._avoid_until[volume_id] = time.monotonic() + _AVOID_SECONDS
191
+
192
+ def _is_avoided(self, volume_id: int) -> bool:
193
+ return self._avoid_until.get(volume_id, 0) > time.monotonic()
194
+
195
+ def _free_bytes(self, volume_id: int) -> int:
196
+ try:
197
+ return self.volumes[volume_id].free_bytes()
198
+ except OSError as error:
199
+ self.report_failure(volume_id, error)
200
+ return 0
201
+
202
+ def follow(self, current: BlobStore) -> None:
203
+ """Take on the volumes and settings of a store opened more recently."""
204
+ self.volumes = current.volumes
205
+ self.copies = current.copies
206
+ self.draining = current.draining
207
+
208
+ def placement_order(self, exclude: Iterable[int] = ()) -> list[Volume]:
209
+ """The volumes new data could go on, the ones to use first."""
210
+ unusable = self.draining.union(exclude)
211
+ free_bytes = {
212
+ volume_id: self._free_bytes(volume_id)
213
+ for volume_id in self.volumes
214
+ if volume_id not in unusable
215
+ }
216
+ order = place({
217
+ key: free for key, free in free_bytes.items() if not self._is_avoided(key)
218
+ }) + place({
219
+ key: free for key, free in free_bytes.items() if self._is_avoided(key)
220
+ })
221
+ return [self.volumes[key] for key in order]
222
+
223
+ def writer(self, hashers: Mapping[str, Hasher]) -> BlobWriter:
224
+ return BlobWriter(self, self.placement_order(), hashers)
225
+
226
+ def can_read(self, blob: Blob) -> bool:
227
+ """Whether any copy is on a volume this worker has open."""
228
+ return any(key in self.volumes for key in blob.volume_ids)
229
+
230
+ def read_order(self, blob: Blob) -> list[Volume]:
231
+ """The volumes to read a blob from, spreading reads over its copies."""
232
+ available = [key for key in blob.volume_ids if key in self.volumes]
233
+ order = _RANDOM.sample(available, len(available))
234
+ order.sort(key=self._is_avoided)
235
+ return [self.volumes[key] for key in order]
236
+
237
+ def delete(self, blob_id: str, volume_ids: Iterable[int]) -> None:
238
+ """Delete what can be reached. `fsck` finds any copy left on a dead disk."""
239
+ for volume_id in volume_ids:
240
+ volume = self.volumes.get(volume_id)
241
+ if volume is None:
242
+ continue
243
+ try:
244
+ volume.delete(blob_id)
245
+ except OSError as error:
246
+ self.report_failure(volume_id, error)
247
+
248
+ def sweep_tmp(self, now: float) -> int:
249
+ removed = 0
250
+ for volume_id, volume in self.volumes.items():
251
+ try:
252
+ removed += volume.sweep_tmp(now)
253
+ except OSError as error:
254
+ self.report_failure(volume_id, error)
255
+ return removed
256
+
257
+
258
+ def create_volume(root: Path, identity: str) -> None:
259
+ """Lay out a new volume in an existing directory that holds nothing else.
260
+
261
+ The directory must exist already: creating it would hide an unmounted disk.
262
+ """
263
+ if not root.is_dir():
264
+ message = f"{root} isn't a directory"
265
+ raise VolumeError(message)
266
+ if {entry.name for entry in root.iterdir()} - _ALLOWED_IN_A_NEW_VOLUME:
267
+ message = f"{root} isn't empty"
268
+ raise VolumeError(message)
269
+ root.chmod(0o700)
270
+ volume = Volume(root, _ID_NOT_ASSIGNED_YET)
271
+ volume.initialize()
272
+ volume.claim(identity)
273
+
274
+
275
+ def open_volume(data_dir: Path, record: VolumeRecord) -> Volume:
276
+ """Check a volume is the one the metadata expects.
277
+
278
+ An unmounted disk leaves an empty directory, and writing there would fill the disk
279
+ underneath and hide those blobs later. So a volume must identify itself.
280
+ """
281
+ root = data_dir / record.path
282
+ try:
283
+ identity = json.loads((root / VOLUME_FILE).read_text("utf-8"))["identity"]
284
+ except (OSError, ValueError, KeyError) as error:
285
+ message = (
286
+ f"volume {record.volume_id} at {root} can't be used, "
287
+ f"is its disk mounted? {error}"
288
+ )
289
+ raise VolumeError(message) from error
290
+ if identity != record.identity:
291
+ message = (
292
+ f"{root} holds volume {identity}, but the metadata expects "
293
+ f"{record.identity} there: these belong to different stores"
294
+ )
295
+ raise VolumeError(message)
296
+ return Volume(root, record.volume_id)
297
+
298
+
299
+ def open_store(
300
+ data_dir: Path,
301
+ records: Sequence[VolumeRecord],
302
+ copies: int = 1,
303
+ *,
304
+ allow_degraded: bool = False,
305
+ ) -> BlobStore:
306
+ """Open every volume, or, if allowed, those that are available."""
307
+ volumes = {}
308
+ for record in records:
309
+ try:
310
+ volumes[record.volume_id] = open_volume(data_dir, record)
311
+ except VolumeError as error:
312
+ if not allow_degraded:
313
+ raise
314
+ logger.warning("Starting without a volume: %s", error)
315
+ if not volumes:
316
+ message = f"none of the volumes of {data_dir} can be used"
317
+ raise VolumeError(message)
318
+ draining = frozenset(record.volume_id for record in records if record.draining)
319
+ return BlobStore(volumes, copies, draining)
320
+
321
+
322
+ class _Copy:
323
+ """One volume's file of a blob being written."""
324
+
325
+ def __init__(self, volume: Volume, blob_id: str) -> None:
326
+ self.volume = volume
327
+ self.final_path = volume.path(blob_id)
328
+ # Its own name: two workers may repair a blob at once, and both are good.
329
+ self._tmp_path = volume.tmp_dir / secrets.token_hex(16)
330
+ self._file = self._tmp_path.open("xb")
331
+ self._committed = False
332
+
333
+ def write(self, data: bytes) -> None:
334
+ self._file.write(data)
335
+
336
+ def commit(self) -> None:
337
+ self._file.flush()
338
+ # ponytail: on macOS fsync doesn't flush the drive cache. Linux is the
339
+ # target; add fcntl F_FULLFSYNC if it is ever deployed on macOS.
340
+ # `make power` removes this line for its control: keep it as it is.
341
+ os.fsync(self._file.fileno())
342
+ self._file.close()
343
+ self._tmp_path.rename(self.final_path)
344
+ fsync_directory(self.final_path.parent)
345
+ self._committed = True
346
+
347
+ def discard(self) -> None:
348
+ """Remove what can be removed. On a dead disk, the temp sweep finishes it."""
349
+ with suppress(OSError):
350
+ self._file.close()
351
+ if not self._committed:
352
+ with suppress(OSError):
353
+ self._tmp_path.unlink(missing_ok=True)
354
+
355
+
356
+ class CorruptSourceError(Exception):
357
+ """A copy read back with the wrong bytes, so it can't be the source of another."""
358
+
359
+
360
+ class TargetVolumeError(Exception):
361
+ """The volume receiving a new copy failed; the source may be fine."""
362
+
363
+
364
+ async def copy_blob(store: BlobStore, blob: Blob, target: Volume) -> None:
365
+ """Make another copy of a blob durable on a volume, checking every byte first.
366
+
367
+ The caller records it afterwards, so a crash leaves at worst an orphan.
368
+ """
369
+ try:
370
+ copy = _Copy(target, blob.blob_id)
371
+ except OSError as error:
372
+ raise TargetVolumeError(str(error)) from error
373
+ digest = hashlib.md5(usedforsecurity=False)
374
+
375
+ def write(data: bytes) -> None:
376
+ digest.update(data)
377
+ try:
378
+ copy.write(data)
379
+ except OSError as error:
380
+ raise TargetVolumeError(str(error)) from error
381
+
382
+ try:
383
+ whole = BlobSlice(blob, 0, blob.size)
384
+ async with aclosing(read_slices(store, [whole])) as chunks:
385
+ async for data in chunks:
386
+ await asyncio.to_thread(write, data)
387
+ if digest.hexdigest() != blob.md5:
388
+ message = f"Blob {blob.blob_id} doesn't match its MD5"
389
+ raise CorruptSourceError(message)
390
+ try:
391
+ await asyncio.to_thread(copy.commit)
392
+ except OSError as error:
393
+ raise TargetVolumeError(str(error)) from error
394
+ finally:
395
+ copy.discard()
396
+
397
+
398
+ class BlobWriter:
399
+ """One blob being written, to as many volumes as the store keeps copies on.
400
+
401
+ It only becomes durable and named on `commit`. A copy that fails is dropped, and
402
+ the blob survives as long as one remains: refusing the write wouldn't make the
403
+ remaining copies any safer, and repair restores the number later.
404
+ """
405
+
406
+ def __init__(
407
+ self, store: BlobStore, volumes: Iterable[Volume], hashers: Mapping[str, Hasher]
408
+ ) -> None:
409
+ self._store = store
410
+ self._hashers = hashers
411
+ self.blob_id = secrets.token_hex(16)
412
+ self._copies: list[_Copy] = []
413
+ self._dropped: list[_Copy] = []
414
+ self._last_error: OSError | None = None
415
+ self.size = 0
416
+ for volume in volumes:
417
+ if len(self._copies) == store.copies:
418
+ break
419
+ try:
420
+ self._copies.append(_Copy(volume, self.blob_id))
421
+ except OSError as error:
422
+ self._record_failure(volume, error)
423
+ self._require_a_copy()
424
+
425
+ def _record_failure(self, volume: Volume, error: OSError) -> None:
426
+ self._last_error = error
427
+ self._store.report_failure(volume.volume_id, error)
428
+
429
+ def _require_a_copy(self) -> None:
430
+ if not self._copies:
431
+ message = f"no volume could store blob {self.blob_id}"
432
+ raise OSError(message) from self._last_error
433
+
434
+ def _drop(self, copy: _Copy, error: OSError) -> None:
435
+ self._record_failure(copy.volume, error)
436
+ self._copies.remove(copy)
437
+ self._dropped.append(copy)
438
+
439
+ def __enter__(self) -> Self:
440
+ return self
441
+
442
+ def __exit__(
443
+ self,
444
+ exc_type: type[BaseException] | None,
445
+ exc: BaseException | None,
446
+ traceback: TracebackType | None,
447
+ ) -> None:
448
+ for copy in (*self._copies, *self._dropped):
449
+ copy.discard()
450
+
451
+ def write(self, data: bytes) -> None:
452
+ """Hash once and write every copy. Runs on a worker thread."""
453
+ for hasher in self._hashers.values():
454
+ hasher.update(data)
455
+ for copy in list(self._copies):
456
+ try:
457
+ copy.write(data)
458
+ except OSError as error:
459
+ self._drop(copy, error)
460
+ self._require_a_copy()
461
+ self.size += len(data)
462
+
463
+ def digests(self) -> dict[str, bytes]:
464
+ return {name: hasher.digest() for name, hasher in self._hashers.items()}
465
+
466
+ def commit(self) -> Blob:
467
+ """Make the blob durable under its final name. Runs on a worker thread.
468
+
469
+ Copies commit in parallel, so that a PUT waits for the slowest disk and not
470
+ for the sum of them.
471
+ """
472
+ copies = list(self._copies)
473
+ if len(copies) == 1:
474
+ errors = [_commit_copy(copies[0])]
475
+ else:
476
+ with ThreadPoolExecutor(len(copies)) as pool:
477
+ errors = list(pool.map(_commit_copy, copies))
478
+ for copy, error in zip(copies, errors, strict=True):
479
+ if error is not None:
480
+ self._drop(copy, error)
481
+ self._require_a_copy()
482
+ return Blob(
483
+ self.blob_id,
484
+ self.size,
485
+ self._hashers["md5"].digest().hex(),
486
+ tuple(copy.volume.volume_id for copy in self._copies),
487
+ )
488
+
489
+
490
+ def _commit_copy(copy: _Copy) -> OSError | None:
491
+ try:
492
+ copy.commit()
493
+ except OSError as error:
494
+ return error
495
+ return None
496
+
497
+
498
+ async def write_stream(
499
+ writer: BlobWriter, chunks: AsyncIterable[bytes], max_bytes: int
500
+ ) -> None:
501
+ """Feed a stream to a writer, one thread-pool trip per buffer of data."""
502
+ buffer = bytearray()
503
+ received = 0
504
+ async for chunk in chunks:
505
+ received += len(chunk)
506
+ if received > max_bytes:
507
+ raise S3Error(errors.ENTITY_TOO_LARGE)
508
+ buffer += chunk
509
+ if len(buffer) >= BUFFER_BYTES:
510
+ await asyncio.to_thread(writer.write, bytes(buffer))
511
+ buffer.clear()
512
+ if buffer:
513
+ await asyncio.to_thread(writer.write, bytes(buffer))
514
+
515
+
516
+ class _SliceReader:
517
+ """Reads a byte range of a blob, moving to the next copy when one fails.
518
+
519
+ Moving is always safe at the same offset: blobs are immutable, so their copies are
520
+ identical.
521
+ """
522
+
523
+ def __init__(self, store: BlobStore, blob_slice: BlobSlice) -> None:
524
+ self._store = store
525
+ self._blob = blob_slice.blob
526
+ self._position = blob_slice.offset
527
+ self._remaining = blob_slice.length
528
+ self._volumes = store.read_order(self._blob)
529
+ self._volume: Volume | None = None
530
+ self._descriptor: int | None = None
531
+ self._failure: OSError | None = None
532
+
533
+ async def _open_next_copy(self) -> int:
534
+ while self._volumes:
535
+ volume = self._volumes.pop(0)
536
+ path = volume.path(self._blob.blob_id)
537
+ try:
538
+ descriptor = await asyncio.to_thread(os.open, path, os.O_RDONLY)
539
+ except FileNotFoundError as error:
540
+ self._failure = error # The blob is gone. The disk may be fine.
541
+ except OSError as error:
542
+ self._failure = error
543
+ self._store.report_failure(volume.volume_id, error)
544
+ else:
545
+ self._volume = volume
546
+ self._descriptor = descriptor
547
+ return descriptor
548
+ message = f"No copy of blob {self._blob.blob_id} could be read: {self._failure}"
549
+ raise OSError(message) from self._failure
550
+
551
+ async def _read_buffer(self, descriptor: int) -> bytes:
552
+ size = min(BUFFER_BYTES, self._remaining)
553
+ data = await asyncio.to_thread(os.pread, descriptor, size, self._position)
554
+ if not data:
555
+ message = f"Blob {self._blob.blob_id} is shorter than its metadata."
556
+ raise OSError(message)
557
+ return data
558
+
559
+ async def next_buffer(self) -> bytes:
560
+ """Return the next buffer of data, empty at the end."""
561
+ while self._remaining > 0:
562
+ descriptor = self._descriptor
563
+ if descriptor is None:
564
+ descriptor = await self._open_next_copy()
565
+ try:
566
+ data = await self._read_buffer(descriptor)
567
+ except OSError as error:
568
+ self._failure = error
569
+ if self._volume is not None:
570
+ self._store.report_failure(self._volume.volume_id, error)
571
+ self.close()
572
+ continue
573
+ self._position += len(data)
574
+ self._remaining -= len(data)
575
+ return data
576
+ return b""
577
+
578
+ def close(self) -> None:
579
+ if self._descriptor is not None:
580
+ os.close(self._descriptor)
581
+ self._descriptor = None
582
+
583
+
584
+ async def read_slices(
585
+ store: BlobStore, slices: Iterable[BlobSlice]
586
+ ) -> AsyncGenerator[bytes]:
587
+ """Stream byte ranges of blobs, opening each blob only when it is reached."""
588
+ for blob_slice in slices:
589
+ reader = _SliceReader(store, blob_slice)
590
+ try:
591
+ while data := await reader.next_buffer():
592
+ yield data
593
+ finally:
594
+ reader.close()