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/__init__.py +1 -0
- leblob/asgi.py +376 -0
- leblob/blobstore.py +594 -0
- leblob/cli.py +499 -0
- leblob/handlers.py +1119 -0
- leblob/maintenance.py +444 -0
- leblob/meta.py +2327 -0
- leblob/metrics.py +226 -0
- leblob/model.py +339 -0
- leblob/s3/__init__.py +1 -0
- leblob/s3/access.py +110 -0
- leblob/s3/chunked.py +175 -0
- leblob/s3/conditions.py +156 -0
- leblob/s3/cors.py +80 -0
- leblob/s3/errors.py +144 -0
- leblob/s3/lifecycle.py +119 -0
- leblob/s3/listing.py +196 -0
- leblob/s3/multipart.py +184 -0
- leblob/s3/payload.py +328 -0
- leblob/s3/post_form.py +344 -0
- leblob/s3/routing.py +443 -0
- leblob/s3/sigv4.py +333 -0
- leblob/s3/sse.py +229 -0
- leblob/s3/tagging.py +48 -0
- leblob/s3/xml.py +436 -0
- leblob/s3/xml_base.py +64 -0
- leblob/s3/xml_config.py +431 -0
- leblob/streams.py +179 -0
- leblob-0.4.0.dev1.dist-info/METADATA +109 -0
- leblob-0.4.0.dev1.dist-info/RECORD +33 -0
- leblob-0.4.0.dev1.dist-info/WHEEL +4 -0
- leblob-0.4.0.dev1.dist-info/entry_points.txt +3 -0
- leblob-0.4.0.dev1.dist-info/licenses/LICENSE +202 -0
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()
|