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/meta.py ADDED
@@ -0,0 +1,2327 @@
1
+ """Every SQL statement, with one public function per transaction.
2
+
3
+ Transaction functions are plain synchronous code on a worker thread, so none of
4
+ them can `await` while holding a transaction open: a slow client can never block
5
+ a writer. Callers see them as `await meta.something(db, ...)`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import fcntl
12
+ import heapq
13
+ import json
14
+ import os
15
+ import secrets
16
+ import shutil
17
+ import sqlite3
18
+ import threading
19
+ import time
20
+ from collections import deque
21
+ from concurrent.futures import Future, ThreadPoolExecutor
22
+ from dataclasses import dataclass, replace
23
+ from functools import partial, wraps
24
+ from itertools import groupby, starmap
25
+ from operator import attrgetter, itemgetter
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from leblob.blobstore import fsync_directory
29
+ from leblob.model import (
30
+ NULL_VERSION_ID,
31
+ Blob,
32
+ BucketRecord,
33
+ Checksum,
34
+ ChecksumPlan,
35
+ ChecksumType,
36
+ CompletedUpload,
37
+ DeleteOutcome,
38
+ KeyVerdict,
39
+ KnownBlob,
40
+ LifecycleReport,
41
+ LifecycleRule,
42
+ ObjectRecord,
43
+ ObjectRef,
44
+ ObjectSummary,
45
+ Part,
46
+ RepairWork,
47
+ Upload,
48
+ UploadExtras,
49
+ Versioning,
50
+ VersionSummary,
51
+ VolumeRecord,
52
+ )
53
+ from leblob.s3 import (
54
+ conditions,
55
+ errors,
56
+ lifecycle,
57
+ listing,
58
+ multipart,
59
+ sse,
60
+ xml_config,
61
+ )
62
+ from leblob.s3.access import FULL, Level, Scope
63
+ from leblob.s3.errors import S3Error
64
+
65
+ if TYPE_CHECKING:
66
+ from collections.abc import (
67
+ Awaitable,
68
+ Callable,
69
+ Iterable,
70
+ Iterator,
71
+ Mapping,
72
+ Sequence,
73
+ )
74
+ from pathlib import Path
75
+ from typing import BinaryIO, Concatenate
76
+
77
+ from leblob.model import CompletionRequest, DeleteTarget, UploadRef
78
+ from leblob.s3.conditions import WriteCondition
79
+ from leblob.s3.listing import ListPage, ListQuery
80
+
81
+ GC_GRACE_MS = 3_600_000
82
+
83
+ DAY_MS = 24 * 3600 * 1000
84
+ SCRUB_REST_MS = DAY_MS # Between passes, so an idle store stays idle.
85
+
86
+ WRITER_LOCK_SUFFIX = ".writer-lock"
87
+
88
+ _BEGIN_WRITE = "BEGIN IMMEDIATE"
89
+ _BUSY_TIMEOUT_MS = 30_000
90
+ _READ_THREADS = 4
91
+ _WRITE_THREADS = 1 # Writers go one at a time, across every worker.
92
+ _MOST_WRITES_IN_A_COMMIT = 32 # So that the writer lock isn't held for long.
93
+ _MILLISECONDS = 1000
94
+ _NO_LIMIT = -1 # As SQLite understands it.
95
+ _VERSION_ID_BYTES = 24
96
+ _SCRUB_MIN_BYTES_PER_BLOB = 64 * 1024 # So that tiny blobs still cost their seek.
97
+ NO_EXTRAS = UploadExtras()
98
+ _LIFECYCLE_BATCH = 1000
99
+ LIFECYCLE_BATCH_REMOVALS = (
100
+ 100 # Removing is what holds up writers: 1000 took up to 2 s.
101
+ )
102
+ # ponytail: one rate for any store, slow enough to leave the writer to clients: about
103
+ # 80 min to walk 10M keys, and about 17M removals a day. Make them settings if needed.
104
+ _LIFECYCLE_VERSIONS_PER_SECOND = 2000
105
+ _LIFECYCLE_REMOVALS_PER_SECOND = 250
106
+ _AFTER = "\x00" # Sorts straight after a name, since neither keys nor buckets hold it.
107
+
108
+ # What a version holds is listed in each statement that moves one, in this order, and
109
+ # `test_every_column_of_a_version_is_moved_with_it` holds them to the tables.
110
+ _BLOBS_OF_CURRENT_VERSION = """
111
+ SELECT blob.blob_id, blob.size, blob.md5,
112
+ (SELECT group_concat(here.volume_id) FROM blob_locations AS here
113
+ WHERE here.blob_id = blob.blob_id)
114
+ FROM object_blobs AS link
115
+ JOIN blobs AS blob ON blob.blob_id = link.blob_id
116
+ WHERE link.bucket = ? AND link.key = ?
117
+ ORDER BY link.position
118
+ """
119
+ _BLOBS_OF_OLDER_VERSION = """
120
+ SELECT blob.blob_id, blob.size, blob.md5,
121
+ (SELECT group_concat(here.volume_id) FROM blob_locations AS here
122
+ WHERE here.blob_id = blob.blob_id)
123
+ FROM version_blobs AS link
124
+ JOIN blobs AS blob ON blob.blob_id = link.blob_id
125
+ WHERE link.bucket = ? AND link.key = ? AND link.version_id = ?
126
+ ORDER BY link.position
127
+ """
128
+ _LIST_OBJECTS = """
129
+ SELECT key, size, etag, modified_at FROM objects
130
+ WHERE bucket = ? AND key >= ?
131
+ ORDER BY key LIMIT ?
132
+ """
133
+ _LIST_CURRENT_VERSIONS = """
134
+ SELECT key, version_id, -9223372036854775808, 0, size, etag, modified_at, tags
135
+ FROM objects WHERE bucket = ? AND key >= ? ORDER BY key
136
+ """
137
+ _LIST_OLDER_VERSIONS = """
138
+ SELECT key, version_id, rank, is_delete_marker, size, etag, modified_at, tags
139
+ FROM object_versions INDEXED BY object_versions_by_rank
140
+ WHERE bucket = ? AND key >= ? ORDER BY key, rank
141
+ """
142
+ _LIST_OBJECTS_BELOW = """
143
+ SELECT key, size, etag, modified_at FROM objects
144
+ WHERE bucket = ? AND key >= ? AND key < ?
145
+ ORDER BY key LIMIT ?
146
+ """
147
+
148
+ MIGRATIONS = (
149
+ """
150
+ CREATE TABLE buckets (
151
+ name TEXT PRIMARY KEY,
152
+ created_at INTEGER NOT NULL
153
+ ) STRICT, WITHOUT ROWID;
154
+
155
+ CREATE TABLE blobs (
156
+ blob_id TEXT PRIMARY KEY,
157
+ size INTEGER NOT NULL,
158
+ md5 TEXT NOT NULL
159
+ ) STRICT, WITHOUT ROWID;
160
+
161
+ CREATE TABLE objects (
162
+ bucket TEXT NOT NULL REFERENCES buckets(name),
163
+ key TEXT NOT NULL,
164
+ size INTEGER NOT NULL,
165
+ etag TEXT NOT NULL,
166
+ modified_at INTEGER NOT NULL,
167
+ headers TEXT NOT NULL,
168
+ checksum TEXT,
169
+ PRIMARY KEY (bucket, key)
170
+ ) STRICT, WITHOUT ROWID;
171
+
172
+ CREATE TABLE object_blobs (
173
+ bucket TEXT NOT NULL,
174
+ key TEXT NOT NULL,
175
+ position INTEGER NOT NULL,
176
+ blob_id TEXT NOT NULL REFERENCES blobs(blob_id),
177
+ PRIMARY KEY (bucket, key, position),
178
+ FOREIGN KEY (bucket, key) REFERENCES objects(bucket, key) ON DELETE CASCADE
179
+ ) STRICT, WITHOUT ROWID;
180
+
181
+ CREATE TABLE uploads (
182
+ upload_id TEXT PRIMARY KEY,
183
+ bucket TEXT NOT NULL REFERENCES buckets(name),
184
+ key TEXT NOT NULL,
185
+ headers TEXT NOT NULL,
186
+ created_at INTEGER NOT NULL
187
+ ) STRICT, WITHOUT ROWID;
188
+
189
+ CREATE INDEX uploads_by_key ON uploads (bucket, key, upload_id);
190
+
191
+ CREATE TABLE upload_parts (
192
+ upload_id TEXT NOT NULL REFERENCES uploads(upload_id) ON DELETE CASCADE,
193
+ part_number INTEGER NOT NULL,
194
+ blob_id TEXT NOT NULL REFERENCES blobs(blob_id),
195
+ checksum TEXT,
196
+ modified_at INTEGER NOT NULL,
197
+ PRIMARY KEY (upload_id, part_number)
198
+ ) STRICT, WITHOUT ROWID;
199
+
200
+ CREATE TABLE completed_uploads (
201
+ upload_id TEXT PRIMARY KEY,
202
+ bucket TEXT NOT NULL,
203
+ key TEXT NOT NULL,
204
+ etag TEXT NOT NULL,
205
+ completed_at INTEGER NOT NULL
206
+ ) STRICT, WITHOUT ROWID;
207
+
208
+ CREATE TABLE blob_gc (
209
+ blob_id TEXT PRIMARY KEY,
210
+ due_at INTEGER NOT NULL
211
+ ) STRICT, WITHOUT ROWID;
212
+
213
+ CREATE INDEX blob_gc_by_due ON blob_gc (due_at);
214
+
215
+ CREATE TABLE access_keys (
216
+ access_key TEXT PRIMARY KEY,
217
+ secret TEXT NOT NULL,
218
+ created_at INTEGER NOT NULL
219
+ ) STRICT, WITHOUT ROWID;
220
+ """,
221
+ # Keys from before this migration stay as they were: full access.
222
+ """
223
+ ALTER TABLE access_keys ADD COLUMN scoped INTEGER NOT NULL DEFAULT 0
224
+ CHECK (scoped IN (0, 1));
225
+
226
+ CREATE TABLE key_grants (
227
+ access_key TEXT NOT NULL REFERENCES access_keys(access_key) ON DELETE CASCADE,
228
+ bucket TEXT NOT NULL REFERENCES buckets(name) ON DELETE CASCADE,
229
+ level INTEGER NOT NULL CHECK (level IN (1, 2, 3)),
230
+ PRIMARY KEY (access_key, bucket)
231
+ ) STRICT, WITHOUT ROWID;
232
+
233
+ CREATE INDEX key_grants_by_bucket ON key_grants (bucket);
234
+ """,
235
+ # Uploads and objects from before this migration have no checksum to keep.
236
+ """
237
+ ALTER TABLE uploads ADD COLUMN checksum_algorithm TEXT;
238
+ ALTER TABLE uploads ADD COLUMN checksum_type TEXT
239
+ CHECK (checksum_type IN ('COMPOSITE', 'FULL_OBJECT'));
240
+ ALTER TABLE completed_uploads ADD COLUMN checksum TEXT;
241
+ """,
242
+ # Without these, forgetting a blob scans both tables to check nothing refers to it.
243
+ """
244
+ CREATE INDEX object_blobs_by_blob ON object_blobs (blob_id);
245
+ CREATE INDEX upload_parts_by_blob ON upload_parts (blob_id);
246
+ """,
247
+ # The data directory becomes the first volume, with its files where they are.
248
+ """
249
+ CREATE TABLE volumes (
250
+ volume_id INTEGER PRIMARY KEY,
251
+ identity TEXT NOT NULL UNIQUE,
252
+ path TEXT NOT NULL UNIQUE
253
+ ) STRICT;
254
+
255
+ INSERT INTO volumes (volume_id, identity, path)
256
+ VALUES (1, lower(hex(randomblob(16))), '.');
257
+
258
+ CREATE TABLE blob_locations (
259
+ blob_id TEXT NOT NULL REFERENCES blobs(blob_id) ON DELETE CASCADE,
260
+ volume_id INTEGER NOT NULL REFERENCES volumes(volume_id),
261
+ PRIMARY KEY (blob_id, volume_id)
262
+ ) STRICT, WITHOUT ROWID;
263
+
264
+ INSERT INTO blob_locations (blob_id, volume_id) SELECT blob_id, 1 FROM blobs;
265
+
266
+ CREATE INDEX blob_locations_by_volume ON blob_locations (volume_id);
267
+ """,
268
+ # How many copies new blobs get. It is here so that a restart can't lower it.
269
+ """
270
+ CREATE TABLE settings (
271
+ only_row INTEGER PRIMARY KEY CHECK (only_row = 1),
272
+ copies INTEGER NOT NULL CHECK (copies >= 1)
273
+ ) STRICT;
274
+
275
+ INSERT INTO settings (only_row, copies) VALUES (1, 1);
276
+ """,
277
+ # A queue rather than a scan: finding short blobs by walking them all, every few
278
+ # seconds, would cost a read of the whole table on a store with nothing to repair.
279
+ """
280
+ ALTER TABLE volumes ADD COLUMN draining INTEGER NOT NULL DEFAULT 0
281
+ CHECK (draining IN (0, 1));
282
+ ALTER TABLE settings ADD COLUMN scrub_cursor TEXT NOT NULL DEFAULT '';
283
+ ALTER TABLE settings ADD COLUMN scrub_not_before INTEGER NOT NULL DEFAULT 0;
284
+
285
+ CREATE TABLE repair_queue (
286
+ blob_id TEXT PRIMARY KEY REFERENCES blobs(blob_id) ON DELETE CASCADE,
287
+ due_at INTEGER NOT NULL
288
+ ) STRICT, WITHOUT ROWID;
289
+
290
+ CREATE INDEX repair_queue_by_due ON repair_queue (due_at);
291
+
292
+ INSERT INTO repair_queue (blob_id, due_at)
293
+ SELECT blob_id, 0 FROM blob_locations GROUP BY blob_id
294
+ HAVING count(*) < (SELECT copies FROM settings);
295
+ """,
296
+ # The current version stays in `objects`, so listing and reading it cost what they
297
+ # did. Rows here are older versions and delete markers. `rank` falls as they are
298
+ # added, so an index walks a key's versions newest first. A current version is
299
+ # always newer than any of them, or there is none.
300
+ """
301
+ ALTER TABLE buckets ADD COLUMN versioning TEXT
302
+ CHECK (versioning IN ('Enabled', 'Suspended'));
303
+ ALTER TABLE objects ADD COLUMN version_id TEXT NOT NULL DEFAULT 'null';
304
+ ALTER TABLE completed_uploads ADD COLUMN version_id TEXT;
305
+
306
+ CREATE TABLE object_versions (
307
+ bucket TEXT NOT NULL REFERENCES buckets(name),
308
+ key TEXT NOT NULL,
309
+ version_id TEXT NOT NULL,
310
+ rank INTEGER NOT NULL,
311
+ is_delete_marker INTEGER NOT NULL CHECK (is_delete_marker IN (0, 1)),
312
+ size INTEGER NOT NULL,
313
+ etag TEXT NOT NULL,
314
+ modified_at INTEGER NOT NULL,
315
+ headers TEXT NOT NULL,
316
+ checksum TEXT,
317
+ PRIMARY KEY (bucket, key, version_id)
318
+ ) STRICT, WITHOUT ROWID;
319
+
320
+ CREATE UNIQUE INDEX object_versions_by_rank
321
+ ON object_versions (bucket, key, rank);
322
+
323
+ CREATE TABLE version_blobs (
324
+ bucket TEXT NOT NULL,
325
+ key TEXT NOT NULL,
326
+ version_id TEXT NOT NULL,
327
+ position INTEGER NOT NULL,
328
+ blob_id TEXT NOT NULL REFERENCES blobs(blob_id),
329
+ PRIMARY KEY (bucket, key, version_id, position),
330
+ FOREIGN KEY (bucket, key, version_id)
331
+ REFERENCES object_versions(bucket, key, version_id) ON DELETE CASCADE
332
+ ) STRICT, WITHOUT ROWID;
333
+
334
+ CREATE INDEX version_blobs_by_blob ON version_blobs (blob_id);
335
+ """,
336
+ # A bucket's rules are kept as the XML a client gets back. The cursor and pace are
337
+ # shared by every worker, and a pass carries on from where it was after a restart.
338
+ """
339
+ ALTER TABLE buckets ADD COLUMN lifecycle TEXT;
340
+ ALTER TABLE settings ADD COLUMN lifecycle_bucket TEXT NOT NULL DEFAULT '';
341
+ ALTER TABLE settings ADD COLUMN lifecycle_key TEXT NOT NULL DEFAULT '';
342
+ ALTER TABLE settings ADD COLUMN lifecycle_not_before INTEGER NOT NULL DEFAULT 0;
343
+ """,
344
+ # Empty for nearly every object, so it costs little, and moves with its version.
345
+ """
346
+ ALTER TABLE objects ADD COLUMN tags TEXT;
347
+ ALTER TABLE object_versions ADD COLUMN tags TEXT;
348
+ ALTER TABLE uploads ADD COLUMN tags TEXT;
349
+ """,
350
+ "ALTER TABLE buckets ADD COLUMN cors TEXT;",
351
+ # Whether anyone may list a bucket, or read a version of an object. S3 keeps the
352
+ # two apart: a public bucket doesn't make its objects public, or the reverse.
353
+ """
354
+ ALTER TABLE buckets ADD COLUMN public INTEGER NOT NULL DEFAULT 0;
355
+ ALTER TABLE objects ADD COLUMN public INTEGER NOT NULL DEFAULT 0;
356
+ ALTER TABLE object_versions ADD COLUMN public INTEGER NOT NULL DEFAULT 0;
357
+ ALTER TABLE uploads ADD COLUMN public INTEGER NOT NULL DEFAULT 0;
358
+ """,
359
+ # Whether a version is encrypted, and how to tell the right key when it is sent.
360
+ """
361
+ ALTER TABLE objects ADD COLUMN sse_check TEXT;
362
+ ALTER TABLE object_versions ADD COLUMN sse_check TEXT;
363
+ ALTER TABLE uploads ADD COLUMN sse_check TEXT;
364
+ """,
365
+ )
366
+
367
+
368
+ def initialize(path: Path) -> None:
369
+ """Create or migrate the database. Runs once, before any worker starts."""
370
+ connection = sqlite3.connect(path, autocommit=True)
371
+ try:
372
+ connection.execute("PRAGMA journal_mode=WAL")
373
+ version = connection.execute("PRAGMA user_version").fetchone()[0]
374
+ for number, script in enumerate(MIGRATIONS[version:], start=version + 1):
375
+ connection.executescript(
376
+ f"BEGIN IMMEDIATE;{script};PRAGMA user_version={number};COMMIT;"
377
+ )
378
+ finally:
379
+ connection.close()
380
+
381
+
382
+ def load_volumes(path: Path) -> tuple[VolumeRecord, ...]:
383
+ """Every volume, first the data directory."""
384
+ connection = sqlite3.connect(path)
385
+ try:
386
+ return _load_volumes(connection)
387
+ finally:
388
+ connection.close()
389
+
390
+
391
+ def _load_volumes(connection: sqlite3.Connection) -> tuple[VolumeRecord, ...]:
392
+ rows = connection.execute(
393
+ "SELECT volume_id, identity, path, draining FROM volumes ORDER BY volume_id"
394
+ )
395
+ return tuple(
396
+ VolumeRecord(volume_id, identity, path, bool(draining))
397
+ for volume_id, identity, path, draining in rows
398
+ )
399
+
400
+
401
+ def load_copies(path: Path) -> int:
402
+ """The number of copies new blobs get, for commands and for starting a server."""
403
+ connection = sqlite3.connect(path)
404
+ try:
405
+ return _copies(connection)
406
+ finally:
407
+ connection.close()
408
+
409
+
410
+ class MetadataUnavailableError(Exception):
411
+ """The metadata can't be read right now, as on a full disk."""
412
+
413
+
414
+ def load_store_settings(path: Path) -> tuple[tuple[VolumeRecord, ...], int]:
415
+ """Every volume and the number of copies, for a worker starting up.
416
+
417
+ On a full disk SQLite may be unable to reopen a database whose WAL needs recovery,
418
+ so this can fail until there is room again.
419
+ """
420
+ try:
421
+ return load_volumes(path), load_copies(path)
422
+ except sqlite3.Error as error:
423
+ raise MetadataUnavailableError(str(error)) from error
424
+
425
+
426
+ class VolumeChangeError(Exception):
427
+ """A change to the volumes that would do harm. The message says why."""
428
+
429
+
430
+ class BackupError(Exception):
431
+ """A backup that can't be made or used. The message says why."""
432
+
433
+
434
+ def backup(path: Path, destination: Path) -> None:
435
+ """Copy the database while it is in use, as one snapshot, durably.
436
+
437
+ The copy takes a single step: one made in small steps starts again whenever a
438
+ writer commits, so under load it might never finish. Writers don't wait for it.
439
+ """
440
+ if destination.exists():
441
+ message = f"{destination} already exists"
442
+ raise BackupError(message)
443
+ partial_copy = destination.with_name(destination.name + ".partial")
444
+ # The copy holds the access keys' secrets, so it is private from the start.
445
+ os.close(os.open(partial_copy, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600))
446
+ try:
447
+ source = sqlite3.connect(path, timeout=_BUSY_TIMEOUT_MS / _MILLISECONDS)
448
+ target = sqlite3.connect(partial_copy)
449
+ try:
450
+ source.backup(target)
451
+ finally:
452
+ target.close()
453
+ source.close()
454
+ _install(partial_copy, destination)
455
+ finally:
456
+ partial_copy.unlink(missing_ok=True)
457
+
458
+
459
+ def restore(backup_path: Path, path: Path) -> None:
460
+ """Put a backup where the database was, refusing to replace anything.
461
+
462
+ A leftover WAL would be replayed over the backup and corrupt it, so it counts as
463
+ being in the way too. The server must be stopped: one still running would keep
464
+ writing to the database it has open.
465
+ """
466
+ if not backup_path.is_file():
467
+ message = f"no backup at {backup_path}"
468
+ raise BackupError(message)
469
+ for suffix in ("", "-wal", "-shm"):
470
+ existing = path.with_name(path.name + suffix)
471
+ if existing.exists():
472
+ message = f"{existing} is in the way; move the old metadata aside first"
473
+ raise BackupError(message)
474
+ partial_copy = path.with_name(path.name + ".restoring")
475
+ shutil.copyfile(backup_path, partial_copy)
476
+ try:
477
+ _check_backup(partial_copy, backup_path)
478
+ _install(partial_copy, path)
479
+ finally:
480
+ partial_copy.unlink(missing_ok=True)
481
+
482
+
483
+ def _check_backup(path: Path, named_as: Path) -> None:
484
+ connection = sqlite3.connect(path)
485
+ try:
486
+ version = connection.execute("PRAGMA user_version").fetchone()[0]
487
+ integrity = connection.execute("PRAGMA integrity_check").fetchall()
488
+ except sqlite3.DatabaseError as error:
489
+ message = f"{named_as} isn't a usable backup: {error}"
490
+ raise BackupError(message) from error
491
+ finally:
492
+ connection.close()
493
+ if integrity != [("ok",)]:
494
+ message = f"{named_as} is damaged: {integrity[0][0]}"
495
+ raise BackupError(message)
496
+ if not 1 <= version <= len(MIGRATIONS):
497
+ message = f"{named_as} isn't a backup this version can use: schema {version}"
498
+ raise BackupError(message)
499
+
500
+
501
+ def _install(partial_copy: Path, destination: Path) -> None:
502
+ descriptor = os.open(partial_copy, os.O_RDONLY)
503
+ try:
504
+ os.fsync(descriptor)
505
+ finally:
506
+ os.close(descriptor)
507
+ partial_copy.rename(destination)
508
+ fsync_directory(destination.parent)
509
+
510
+
511
+ @dataclass(frozen=True, slots=True)
512
+ class _QueuedWrite:
513
+ function: Callable[[sqlite3.Connection], Any]
514
+ future: Future[Any]
515
+
516
+
517
+ def _run_each(
518
+ batch: Sequence[_QueuedWrite], connection: sqlite3.Connection
519
+ ) -> list[tuple[Any, Exception | None]]:
520
+ outcomes: list[tuple[Any, Exception | None]] = []
521
+ for write in batch:
522
+ connection.execute("SAVEPOINT one_write")
523
+ try:
524
+ result = write.function(connection)
525
+ # Anything else is a bug, and fails the whole batch, in the caller's sight.
526
+ except (S3Error, sqlite3.Error, OSError) as error:
527
+ connection.execute("ROLLBACK TO one_write")
528
+ outcomes.append((None, error))
529
+ else:
530
+ outcomes.append((result, None))
531
+ connection.execute("RELEASE one_write")
532
+ return outcomes
533
+
534
+
535
+ class Database:
536
+ """Pools of threads, each holding its own SQLite connection.
537
+
538
+ Reads have a pool of their own, because a writer holds its thread while it waits
539
+ for the writer lock. Sharing one, a few queued writers would stop every read.
540
+ """
541
+
542
+ def __init__(self, path: Path) -> None:
543
+ self._path = path
544
+ self._local = threading.local()
545
+ self._connections: list[sqlite3.Connection] = []
546
+ self._writer_locks: list[BinaryIO] = []
547
+ self._lock = threading.Lock()
548
+ self._read_pool = ThreadPoolExecutor(max_workers=_READ_THREADS)
549
+ self._write_pool = ThreadPoolExecutor(max_workers=_WRITE_THREADS)
550
+ self._waiting: deque[_QueuedWrite] = deque()
551
+
552
+ def _open_connection_once(self) -> None:
553
+ """Open this thread's connection, on its first transaction.
554
+
555
+ Not in a pool initializer: one failure there, such as on a full disk, breaks
556
+ the pool until the server restarts. Here it fails one request.
557
+ """
558
+ if hasattr(self._local, "connection"):
559
+ return
560
+ connection = sqlite3.connect(
561
+ self._path,
562
+ autocommit=True,
563
+ timeout=_BUSY_TIMEOUT_MS / _MILLISECONDS,
564
+ check_same_thread=False,
565
+ )
566
+ # In WAL mode, synchronous=NORMAL can lose acknowledged commits on power loss.
567
+ connection.execute("PRAGMA synchronous=FULL")
568
+ connection.execute("PRAGMA foreign_keys=ON")
569
+ writer_lock = self._path.with_name(self._path.name + WRITER_LOCK_SUFFIX).open(
570
+ "ab"
571
+ )
572
+ self._local.connection = connection
573
+ self._local.writer_lock = writer_lock
574
+ with self._lock:
575
+ self._connections.append(connection)
576
+ self._writer_locks.append(writer_lock)
577
+
578
+ def _transact[R](
579
+ self, begin: str, function: Callable[[sqlite3.Connection], R]
580
+ ) -> R:
581
+ self._open_connection_once()
582
+ connection: sqlite3.Connection = self._local.connection
583
+ connection.execute(begin)
584
+ try:
585
+ result = function(connection)
586
+ connection.execute("COMMIT")
587
+ except BaseException:
588
+ # A failed COMMIT (disk full) may already have rolled back. Left open,
589
+ # the transaction would make every later BEGIN on this thread fail.
590
+ if connection.in_transaction:
591
+ connection.execute("ROLLBACK")
592
+ raise
593
+ return result
594
+
595
+ def _transact_in_turn[R](
596
+ self, begin: str, function: Callable[[sqlite3.Connection], R]
597
+ ) -> R:
598
+ """Queue on a kernel lock, across workers, rather than on SQLite's.
599
+
600
+ SQLite's busy handler sleeps up to 100 ms between tries, so writers sleep
601
+ while the lock is free, and an unlucky one waits for seconds. The kernel wakes
602
+ the next writer on release, and releases the lock if its process dies.
603
+ """
604
+ self._open_connection_once()
605
+ writer_lock: BinaryIO = self._local.writer_lock
606
+ fcntl.flock(writer_lock, fcntl.LOCK_EX)
607
+ try:
608
+ return self._transact(begin, function)
609
+ finally:
610
+ fcntl.flock(writer_lock, fcntl.LOCK_UN)
611
+
612
+ def _commit_what_is_waiting(self) -> None:
613
+ """Run the writes queued in this worker in one transaction, with one flush.
614
+
615
+ A commit takes as long as the disk needs to flush, and writers wait in line for
616
+ it, so one commit each caps a store at what one flush at a time allows. Each
617
+ write has a savepoint, so one that fails takes no other with it, and sees the
618
+ ones before it. None is told it succeeded until the commit is durable. If that
619
+ fails, all of them did.
620
+ """
621
+ batch: list[_QueuedWrite] = []
622
+ while self._waiting and len(batch) < _MOST_WRITES_IN_A_COMMIT:
623
+ write = self._waiting.popleft()
624
+ if write.future.set_running_or_notify_cancel():
625
+ batch.append(write)
626
+ if not batch:
627
+ return # An earlier round took them.
628
+ try:
629
+ outcomes = self._transact_in_turn(_BEGIN_WRITE, partial(_run_each, batch))
630
+ except BaseException as error:
631
+ for write in batch:
632
+ write.future.set_exception(error)
633
+ raise
634
+ for write, (result, failure) in zip(batch, outcomes, strict=True):
635
+ if failure is None:
636
+ write.future.set_result(result)
637
+ else:
638
+ write.future.set_exception(failure)
639
+
640
+ async def run[R](
641
+ self, begin: str, function: Callable[[sqlite3.Connection], R]
642
+ ) -> R:
643
+ if begin == _BEGIN_WRITE:
644
+ future: Future[R] = Future()
645
+ self._waiting.append(_QueuedWrite(function, future))
646
+ self._write_pool.submit(self._commit_what_is_waiting)
647
+ return await asyncio.wrap_future(future)
648
+ work = partial(self._transact, begin, function)
649
+ loop = asyncio.get_running_loop()
650
+ return await loop.run_in_executor(self._read_pool, work)
651
+
652
+ def close(self) -> None:
653
+ self._read_pool.shutdown(wait=True)
654
+ self._write_pool.shutdown(wait=True)
655
+ for connection in self._connections:
656
+ connection.close()
657
+ for writer_lock in self._writer_locks:
658
+ writer_lock.close()
659
+
660
+
661
+ def _transaction[**P, R](
662
+ begin: str,
663
+ ) -> Callable[
664
+ [Callable[Concatenate[sqlite3.Connection, P], R]],
665
+ Callable[Concatenate[Database, P], Awaitable[R]],
666
+ ]:
667
+ def decorate(
668
+ function: Callable[Concatenate[sqlite3.Connection, P], R],
669
+ ) -> Callable[Concatenate[Database, P], Awaitable[R]]:
670
+ @wraps(function)
671
+ async def wrapper(db: Database, /, *args: P.args, **kwargs: P.kwargs) -> R:
672
+ return await db.run(
673
+ begin, lambda connection: function(connection, *args, **kwargs)
674
+ )
675
+
676
+ return wrapper
677
+
678
+ return decorate
679
+
680
+
681
+ _read = _transaction("BEGIN")
682
+ _write = _transaction(_BEGIN_WRITE)
683
+
684
+
685
+ def now_ms() -> int:
686
+ return time.time_ns() // 1_000_000
687
+
688
+
689
+ #
690
+ # Access keys
691
+ #
692
+ @_read
693
+ def get_access_key(
694
+ connection: sqlite3.Connection, access_key: str
695
+ ) -> tuple[str, Scope]:
696
+ """A key's secret and scope, read per request: a revocation applies at once."""
697
+ row = connection.execute(
698
+ "SELECT secret, scoped FROM access_keys WHERE access_key = ?", (access_key,)
699
+ ).fetchone()
700
+ if row is None:
701
+ raise S3Error(errors.INVALID_ACCESS_KEY)
702
+ secret, scoped = row
703
+ return secret, _scope(connection, access_key) if scoped else FULL
704
+
705
+
706
+ def _scope(connection: sqlite3.Connection, access_key: str) -> Scope:
707
+ rows = connection.execute(
708
+ "SELECT bucket, level FROM key_grants WHERE access_key = ?", (access_key,)
709
+ )
710
+ return Scope({bucket: Level(level) for bucket, level in rows})
711
+
712
+
713
+ @_read
714
+ def list_access_keys(connection: sqlite3.Connection) -> dict[str, Scope]:
715
+ rows = connection.execute(
716
+ "SELECT access_key, scoped FROM access_keys ORDER BY access_key"
717
+ ).fetchall()
718
+ return {
719
+ access_key: _scope(connection, access_key) if scoped else FULL
720
+ for access_key, scoped in rows
721
+ }
722
+
723
+
724
+ @_write
725
+ def add_access_key(
726
+ connection: sqlite3.Connection,
727
+ access_key: str,
728
+ secret: str,
729
+ *,
730
+ scoped: bool = False,
731
+ ) -> None:
732
+ connection.execute(
733
+ """
734
+ INSERT INTO access_keys (access_key, secret, created_at, scoped)
735
+ VALUES (?, ?, ?, ?)
736
+ """,
737
+ (access_key, secret, now_ms(), scoped),
738
+ )
739
+
740
+
741
+ @_write
742
+ def grant_bucket(
743
+ connection: sqlite3.Connection, access_key: str, bucket: str, level: Level
744
+ ) -> None:
745
+ """Give a scoped key a level on a bucket, replacing any it had."""
746
+ row = connection.execute(
747
+ "SELECT scoped FROM access_keys WHERE access_key = ?", (access_key,)
748
+ ).fetchone()
749
+ if row is None:
750
+ raise S3Error(errors.INVALID_ACCESS_KEY)
751
+ if not row[0]:
752
+ raise S3Error(
753
+ errors.INVALID_REQUEST,
754
+ f"{access_key} has full access: a grant would limit nothing.",
755
+ )
756
+ _require_bucket(connection, bucket)
757
+ connection.execute(
758
+ """
759
+ INSERT INTO key_grants (access_key, bucket, level) VALUES (?, ?, ?)
760
+ ON CONFLICT (access_key, bucket) DO UPDATE SET level = excluded.level
761
+ """,
762
+ (access_key, bucket, int(level)),
763
+ )
764
+
765
+
766
+ @_write
767
+ def revoke_bucket(connection: sqlite3.Connection, access_key: str, bucket: str) -> bool:
768
+ cursor = connection.execute(
769
+ "DELETE FROM key_grants WHERE access_key = ? AND bucket = ?",
770
+ (access_key, bucket),
771
+ )
772
+ return cursor.rowcount > 0
773
+
774
+
775
+ @_write
776
+ def delete_access_key(connection: sqlite3.Connection, access_key: str) -> bool:
777
+ cursor = connection.execute(
778
+ "DELETE FROM access_keys WHERE access_key = ?", (access_key,)
779
+ )
780
+ return cursor.rowcount > 0
781
+
782
+
783
+ @_read
784
+ def ping(connection: sqlite3.Connection) -> None:
785
+ connection.execute("SELECT 1 FROM buckets LIMIT 1").fetchone()
786
+
787
+
788
+ #
789
+ # Buckets
790
+ #
791
+ @_read
792
+ def list_buckets(connection: sqlite3.Connection) -> list[BucketRecord]:
793
+ rows = connection.execute("SELECT name, created_at FROM buckets ORDER BY name")
794
+ return list(starmap(BucketRecord, rows))
795
+
796
+
797
+ @_read
798
+ def require_bucket(connection: sqlite3.Connection, bucket: str) -> None:
799
+ _require_bucket(connection, bucket)
800
+
801
+
802
+ @_write
803
+ def create_bucket(
804
+ connection: sqlite3.Connection, bucket: str, *, public: bool = False
805
+ ) -> None:
806
+ cursor = connection.execute(
807
+ """
808
+ INSERT INTO buckets (name, created_at, public) VALUES (?, ?, ?)
809
+ ON CONFLICT DO NOTHING
810
+ """,
811
+ (bucket, now_ms(), public),
812
+ )
813
+ if cursor.rowcount == 0:
814
+ raise S3Error(errors.BUCKET_ALREADY_OWNED)
815
+
816
+
817
+ @_read
818
+ def bucket_is_public(connection: sqlite3.Connection, bucket: str) -> bool:
819
+ """No bucket and a private one look the same, to those who may not know which."""
820
+ row = connection.execute(
821
+ "SELECT public FROM buckets WHERE name = ?", (bucket,)
822
+ ).fetchone()
823
+ return row is not None and bool(row[0])
824
+
825
+
826
+ @_write
827
+ def set_bucket_public(
828
+ connection: sqlite3.Connection, bucket: str, *, public: bool
829
+ ) -> None:
830
+ _require_bucket(connection, bucket)
831
+ connection.execute("UPDATE buckets SET public = ? WHERE name = ?", (public, bucket))
832
+
833
+
834
+ @_write
835
+ def set_object_public(
836
+ connection: sqlite3.Connection,
837
+ target: ObjectRef,
838
+ version_id: str | None,
839
+ *,
840
+ public: bool,
841
+ ) -> str:
842
+ """Change who may read one version of a key, and return which version that was."""
843
+ bucket, key = target.bucket, target.key
844
+ named = _load_version(connection, bucket, key, version_id).version_id
845
+ parameters = (public, bucket, key, named)
846
+ connection.execute(
847
+ "UPDATE objects SET public = ? WHERE bucket = ? AND key = ? AND version_id = ?",
848
+ parameters,
849
+ )
850
+ connection.execute(
851
+ """
852
+ UPDATE object_versions SET public = ?
853
+ WHERE bucket = ? AND key = ? AND version_id = ?
854
+ """,
855
+ parameters,
856
+ )
857
+ return named
858
+
859
+
860
+ @_write
861
+ def delete_bucket(connection: sqlite3.Connection, bucket: str) -> None:
862
+ _require_bucket(connection, bucket)
863
+ has_objects = connection.execute(
864
+ """
865
+ SELECT 1 FROM objects WHERE bucket = ?
866
+ UNION ALL SELECT 1 FROM object_versions WHERE bucket = ?
867
+ LIMIT 1
868
+ """,
869
+ (bucket, bucket),
870
+ ).fetchone()
871
+ if has_objects:
872
+ raise S3Error(errors.BUCKET_NOT_EMPTY)
873
+
874
+ _queue_for_gc(
875
+ connection,
876
+ """
877
+ SELECT part.blob_id FROM upload_parts AS part
878
+ JOIN uploads ON uploads.upload_id = part.upload_id
879
+ WHERE uploads.bucket = ?
880
+ """,
881
+ (bucket,),
882
+ )
883
+ connection.execute("DELETE FROM uploads WHERE bucket = ?", (bucket,))
884
+ connection.execute("DELETE FROM buckets WHERE name = ?", (bucket,))
885
+
886
+
887
+ #
888
+ # Objects
889
+ #
890
+ @_read
891
+ def get_object(
892
+ connection: sqlite3.Connection,
893
+ bucket: str,
894
+ key: str,
895
+ version_id: str | None = None,
896
+ ) -> ObjectRecord:
897
+ """Read a version, or if none is named, the current one."""
898
+ return _load_version(connection, bucket, key, version_id)
899
+
900
+
901
+ @_write
902
+ def set_object_tags(
903
+ connection: sqlite3.Connection,
904
+ bucket: str,
905
+ key: str,
906
+ version_id: str | None,
907
+ tags: Mapping[str, str],
908
+ ) -> str:
909
+ """Replace a version's tags, and return which version that was.
910
+
911
+ Nothing else about it changes, its time included, so an object doesn't look newer
912
+ to a lifecycle rule for having been tagged.
913
+ """
914
+ named = _load_version(connection, bucket, key, version_id).version_id
915
+ parameters = (_dump_tags(tags), bucket, key, named)
916
+ # The version is in one of these, whichever it is.
917
+ connection.execute(
918
+ "UPDATE objects SET tags = ? WHERE bucket = ? AND key = ? AND version_id = ?",
919
+ parameters,
920
+ )
921
+ connection.execute(
922
+ """
923
+ UPDATE object_versions SET tags = ?
924
+ WHERE bucket = ? AND key = ? AND version_id = ?
925
+ """,
926
+ parameters,
927
+ )
928
+ return named
929
+
930
+
931
+ def _load_version(
932
+ connection: sqlite3.Connection,
933
+ bucket: str,
934
+ key: str,
935
+ version_id: str | None,
936
+ ) -> ObjectRecord:
937
+ row = connection.execute(
938
+ """
939
+ SELECT version_id, size, etag, modified_at, headers, checksum, tags, public,
940
+ sse_check
941
+ FROM objects
942
+ WHERE bucket = ? AND key = ?
943
+ """,
944
+ (bucket, key),
945
+ ).fetchone()
946
+ if row is not None and version_id in {None, row[0]}:
947
+ blobs = connection.execute(_BLOBS_OF_CURRENT_VERSION, (bucket, key)).fetchall()
948
+ return _record_from(key, row[0], row[1:], blobs)
949
+ _require_bucket(connection, bucket)
950
+ if version_id is None:
951
+ raise _no_current_version(connection, bucket, key)
952
+ return _load_older_version(connection, bucket, key, version_id)
953
+
954
+
955
+ def _no_current_version(
956
+ connection: sqlite3.Connection, bucket: str, key: str
957
+ ) -> S3Error:
958
+ """The error for a key with no current version, naming a delete marker if any."""
959
+ marker = connection.execute(
960
+ """
961
+ SELECT version_id FROM object_versions
962
+ WHERE bucket = ? AND key = ? AND is_delete_marker = 1
963
+ AND rank = (SELECT min(rank) FROM object_versions
964
+ WHERE bucket = ? AND key = ?)
965
+ """,
966
+ (bucket, key, bucket, key),
967
+ ).fetchone()
968
+ if marker is None:
969
+ return S3Error(errors.NO_SUCH_KEY)
970
+ return S3Error(errors.NO_SUCH_KEY, headers=_delete_marker_headers(marker[0]))
971
+
972
+
973
+ def _delete_marker_headers(version_id: str) -> tuple[tuple[str, str], ...]:
974
+ return (("x-amz-delete-marker", "true"), ("x-amz-version-id", version_id))
975
+
976
+
977
+ def _load_older_version(
978
+ connection: sqlite3.Connection, bucket: str, key: str, version_id: str
979
+ ) -> ObjectRecord:
980
+ row = connection.execute(
981
+ """
982
+ SELECT is_delete_marker,
983
+ size, etag, modified_at, headers, checksum, tags, public, sse_check
984
+ FROM object_versions
985
+ WHERE bucket = ? AND key = ? AND version_id = ?
986
+ """,
987
+ (bucket, key, version_id),
988
+ ).fetchone()
989
+ if row is None:
990
+ raise S3Error(errors.NO_SUCH_VERSION)
991
+ if row[0]:
992
+ raise S3Error(
993
+ errors.METHOD_NOT_ALLOWED,
994
+ "This version is a delete marker.",
995
+ headers=_delete_marker_headers(version_id),
996
+ )
997
+ blobs = connection.execute(
998
+ _BLOBS_OF_OLDER_VERSION, (bucket, key, version_id)
999
+ ).fetchall()
1000
+ return _record_from(key, version_id, row[1:], blobs)
1001
+
1002
+
1003
+ def _record_from(
1004
+ key: str,
1005
+ version_id: str,
1006
+ fields: Sequence[Any],
1007
+ blobs: Iterable[tuple[str, int, str, str]],
1008
+ ) -> ObjectRecord:
1009
+ """Build a record from a version's fields, which every statement lists alike."""
1010
+ size, etag, modified_at, headers, checksum, tags, public, check = fields
1011
+ return ObjectRecord(
1012
+ key,
1013
+ size,
1014
+ etag,
1015
+ modified_at,
1016
+ json.loads(headers),
1017
+ _load_checksum(checksum),
1018
+ tuple(starmap(_load_blob, blobs)),
1019
+ version_id,
1020
+ _load_tags(tags),
1021
+ bool(public),
1022
+ check,
1023
+ )
1024
+
1025
+
1026
+ @_write
1027
+ def commit_object(
1028
+ connection: sqlite3.Connection,
1029
+ bucket: str,
1030
+ record: ObjectRecord,
1031
+ condition: WriteCondition,
1032
+ ) -> str | None:
1033
+ """Make an object visible. This is the only moment a PUT takes effect.
1034
+
1035
+ Returns its version ID, or None if the bucket has never been versioned.
1036
+ """
1037
+ _check_write(connection, bucket, record.key, condition)
1038
+ _insert_blobs(connection, record.blobs)
1039
+ return _store_as_current(connection, bucket, record)
1040
+
1041
+
1042
+ @_write
1043
+ def delete_object(
1044
+ connection: sqlite3.Connection,
1045
+ bucket: str,
1046
+ key: str,
1047
+ condition: WriteCondition,
1048
+ ) -> DeleteOutcome:
1049
+ _check_write(connection, bucket, key, condition)
1050
+ return _delete_current(connection, bucket, key)
1051
+
1052
+
1053
+ @_write
1054
+ def delete_object_version(
1055
+ connection: sqlite3.Connection, bucket: str, key: str, version_id: str
1056
+ ) -> DeleteOutcome:
1057
+ _require_bucket(connection, bucket)
1058
+ return _erase_version(connection, bucket, key, version_id)
1059
+
1060
+
1061
+ @_write
1062
+ def delete_objects(
1063
+ connection: sqlite3.Connection, bucket: str, targets: Iterable[DeleteTarget]
1064
+ ) -> list[DeleteOutcome]:
1065
+ _require_bucket(connection, bucket)
1066
+ return [
1067
+ _delete_current(connection, bucket, target.key)
1068
+ if target.version_id is None
1069
+ else _erase_version(connection, bucket, target.key, target.version_id)
1070
+ for target in targets
1071
+ ]
1072
+
1073
+
1074
+ @_read
1075
+ def get_versioning(connection: sqlite3.Connection, bucket: str) -> Versioning | None:
1076
+ _require_bucket(connection, bucket)
1077
+ return _versioning(connection, bucket)
1078
+
1079
+
1080
+ @_write
1081
+ def set_versioning(
1082
+ connection: sqlite3.Connection, bucket: str, state: Versioning
1083
+ ) -> None:
1084
+ """Set a bucket's state. Once versioned it can be suspended, never un-versioned."""
1085
+ _require_bucket(connection, bucket)
1086
+ connection.execute(
1087
+ "UPDATE buckets SET versioning = ? WHERE name = ?", (state.value, bucket)
1088
+ )
1089
+
1090
+
1091
+ @_read
1092
+ def list_versions(
1093
+ connection: sqlite3.Connection,
1094
+ bucket: str,
1095
+ query: ListQuery,
1096
+ version_marker: str,
1097
+ ) -> ListPage[VersionSummary]:
1098
+ _require_bucket(connection, bucket)
1099
+ return listing.scan_versions(
1100
+ partial(_versions_from, connection, bucket), query, version_marker
1101
+ )
1102
+
1103
+
1104
+ def _versions_from(
1105
+ connection: sqlite3.Connection, bucket: str, lower: str
1106
+ ) -> Iterator[VersionSummary]:
1107
+ """Every version from a key on, in key order, and newest first within a key."""
1108
+ current = connection.execute(_LIST_CURRENT_VERSIONS, (bucket, lower))
1109
+ older = connection.execute(_LIST_OLDER_VERSIONS, (bucket, lower))
1110
+ rows = heapq.merge(current, older, key=itemgetter(0, 2))
1111
+ return (
1112
+ VersionSummary(
1113
+ key,
1114
+ version_id,
1115
+ rank,
1116
+ bool(marker),
1117
+ size,
1118
+ etag,
1119
+ modified,
1120
+ tags=_load_tags(tags),
1121
+ )
1122
+ for key, version_id, rank, marker, size, etag, modified, tags in rows
1123
+ )
1124
+
1125
+
1126
+ @_read
1127
+ def get_lifecycle(connection: sqlite3.Connection, bucket: str) -> str:
1128
+ _require_bucket(connection, bucket)
1129
+ row = connection.execute(
1130
+ "SELECT lifecycle FROM buckets WHERE name = ?", (bucket,)
1131
+ ).fetchone()
1132
+ if row[0] is None:
1133
+ raise S3Error(errors.NO_SUCH_LIFECYCLE)
1134
+ return row[0]
1135
+
1136
+
1137
+ @_write
1138
+ def set_lifecycle(
1139
+ connection: sqlite3.Connection, bucket: str, configuration: str | None
1140
+ ) -> None:
1141
+ """Replace a bucket's rules, or remove them with None."""
1142
+ _require_bucket(connection, bucket)
1143
+ connection.execute(
1144
+ "UPDATE buckets SET lifecycle = ? WHERE name = ?", (configuration, bucket)
1145
+ )
1146
+
1147
+
1148
+ @_read
1149
+ def get_cors(connection: sqlite3.Connection, bucket: str) -> str | None:
1150
+ """A bucket's CORS rules. No bucket and no rules look the same to a browser."""
1151
+ row = connection.execute(
1152
+ "SELECT cors FROM buckets WHERE name = ?", (bucket,)
1153
+ ).fetchone()
1154
+ return None if row is None else row[0]
1155
+
1156
+
1157
+ @_write
1158
+ def set_cors(
1159
+ connection: sqlite3.Connection, bucket: str, configuration: str | None
1160
+ ) -> None:
1161
+ """Replace a bucket's CORS rules, or remove them with None."""
1162
+ _require_bucket(connection, bucket)
1163
+ connection.execute(
1164
+ "UPDATE buckets SET cors = ? WHERE name = ?", (configuration, bucket)
1165
+ )
1166
+
1167
+
1168
+ @_read
1169
+ def lifecycle_due_at(connection: sqlite3.Connection) -> int:
1170
+ row = connection.execute("SELECT lifecycle_not_before FROM settings").fetchone()
1171
+ return row[0]
1172
+
1173
+
1174
+ @_write
1175
+ def expire_batch(
1176
+ connection: sqlite3.Connection,
1177
+ now: int,
1178
+ day_ms: int,
1179
+ limit: int = _LIFECYCLE_BATCH,
1180
+ ) -> LifecycleReport:
1181
+ """Apply lifecycle rules to the next stretch of keys, in one transaction.
1182
+
1183
+ One transaction is enough because removing an object only moves rows: its bytes
1184
+ wait for the collector. Keeping the cursor and the pace here spreads one store-wide
1185
+ rate over the workers, with no leader.
1186
+ """
1187
+ position, after_key, not_before = connection.execute(
1188
+ "SELECT lifecycle_bucket, lifecycle_key, lifecycle_not_before FROM settings"
1189
+ ).fetchone()
1190
+ if now < not_before:
1191
+ return LifecycleReport()
1192
+ row = connection.execute(
1193
+ """
1194
+ SELECT name, lifecycle FROM buckets
1195
+ WHERE name >= ? AND lifecycle IS NOT NULL ORDER BY name LIMIT 1
1196
+ """,
1197
+ (position,),
1198
+ ).fetchone()
1199
+ if row is None:
1200
+ _move_lifecycle_cursor(connection, "", "", now + day_ms) # A pass has ended.
1201
+ return LifecycleReport()
1202
+ bucket, configuration = row
1203
+ if bucket != position:
1204
+ after_key = ""
1205
+ rules = xml_config.parse_lifecycle(configuration.encode())
1206
+ clock = lifecycle.Clock(now, day_ms)
1207
+
1208
+ verdicts, last_key, seen = _judge_next_keys(
1209
+ connection, ObjectRef(bucket, after_key), rules, clock, limit
1210
+ )
1211
+ stale = _stale_uploads(
1212
+ connection, bucket, _KeyRange(after_key, last_key), rules, clock
1213
+ )
1214
+ for key, verdict in verdicts:
1215
+ for version_id in verdict.erase:
1216
+ _erase_version(connection, bucket, key, version_id)
1217
+ if verdict.expire_current:
1218
+ _delete_current(connection, bucket, key)
1219
+ for upload_id in stale:
1220
+ _remove_upload(connection, upload_id)
1221
+
1222
+ removals = sum(verdict.removals for _, verdict in verdicts) + len(stale)
1223
+ pause_ms = (
1224
+ seen * _MILLISECONDS // _LIFECYCLE_VERSIONS_PER_SECOND
1225
+ + removals * _MILLISECONDS // _LIFECYCLE_REMOVALS_PER_SECOND
1226
+ )
1227
+ if last_key is None:
1228
+ _move_lifecycle_cursor(connection, bucket + _AFTER, "", now + pause_ms)
1229
+ else:
1230
+ _move_lifecycle_cursor(connection, bucket, last_key, now + pause_ms)
1231
+ return LifecycleReport(
1232
+ expired=sum(verdict.expire_current for _, verdict in verdicts),
1233
+ erased=sum(len(verdict.erase) for _, verdict in verdicts),
1234
+ aborted=len(stale),
1235
+ )
1236
+
1237
+
1238
+ @dataclass(frozen=True, slots=True)
1239
+ class _KeyRange:
1240
+ """The keys after one, through another, or to the end with None."""
1241
+
1242
+ after: str
1243
+ through: str | None
1244
+
1245
+
1246
+ def _judge_next_keys(
1247
+ connection: sqlite3.Connection,
1248
+ after: ObjectRef,
1249
+ rules: Sequence[LifecycleRule],
1250
+ clock: lifecycle.Clock,
1251
+ limit: int,
1252
+ ) -> tuple[list[tuple[str, KeyVerdict]], str | None, int]:
1253
+ """Judge whole keys after one, up to a limit. Also returns the last, if more may
1254
+ follow, and how many versions were seen.
1255
+
1256
+ Read before anything changes: what a query sees of changes made while it runs is
1257
+ undefined. A key is never split, since its versions are judged against each other.
1258
+ """
1259
+ # ponytail: a key with a great many versions makes one long batch. Walk it in
1260
+ # stretches, keeping the last version seen, if a store has such keys.
1261
+ verdicts: list[tuple[str, KeyVerdict]] = []
1262
+ seen = removals = 0
1263
+ rows = _versions_from(connection, after.bucket, after.key + _AFTER)
1264
+ for key, group in groupby(rows, key=attrgetter("key")):
1265
+ versions = list(group)
1266
+ verdict = lifecycle.judge_key(rules, versions, clock)
1267
+ verdicts.append((key, verdict))
1268
+ seen += len(versions)
1269
+ removals += verdict.removals
1270
+ if seen >= limit or removals >= LIFECYCLE_BATCH_REMOVALS:
1271
+ return verdicts, key, seen
1272
+ return verdicts, None, seen
1273
+
1274
+
1275
+ def _stale_uploads(
1276
+ connection: sqlite3.Connection,
1277
+ bucket: str,
1278
+ key_range: _KeyRange,
1279
+ rules: Sequence[LifecycleRule],
1280
+ clock: lifecycle.Clock,
1281
+ ) -> list[str]:
1282
+ """Uploads past their time, among the keys this batch covers."""
1283
+ # ponytail: a great many uploads between two keys make one long batch. Count them
1284
+ # towards the limit, if a store collects that many before its first rule.
1285
+ after_key, last_key = key_range.after, key_range.through
1286
+ rows = connection.execute(
1287
+ """
1288
+ SELECT upload_id, key, created_at FROM uploads
1289
+ WHERE bucket = ? AND key > ? AND (? IS NULL OR key <= ?)
1290
+ ORDER BY key
1291
+ """,
1292
+ (bucket, after_key, last_key, last_key),
1293
+ )
1294
+ return [
1295
+ upload_id
1296
+ for upload_id, key, created_at in rows
1297
+ if lifecycle.upload_is_stale(rules, key, created_at, clock)
1298
+ ]
1299
+
1300
+
1301
+ def _move_lifecycle_cursor(
1302
+ connection: sqlite3.Connection, bucket: str, key: str, not_before: int
1303
+ ) -> None:
1304
+ connection.execute(
1305
+ """
1306
+ UPDATE settings
1307
+ SET lifecycle_bucket = ?, lifecycle_key = ?, lifecycle_not_before = ?
1308
+ """,
1309
+ (bucket, key, not_before),
1310
+ )
1311
+
1312
+
1313
+ @_read
1314
+ def list_objects(
1315
+ connection: sqlite3.Connection, bucket: str, query: ListQuery
1316
+ ) -> ListPage:
1317
+ _require_bucket(connection, bucket)
1318
+
1319
+ def fetch(lower: str, upper: str | None, limit: int) -> Iterator[ObjectSummary]:
1320
+ rows = (
1321
+ connection.execute(_LIST_OBJECTS, (bucket, lower, limit))
1322
+ if upper is None
1323
+ else connection.execute(_LIST_OBJECTS_BELOW, (bucket, lower, upper, limit))
1324
+ )
1325
+ return starmap(ObjectSummary, rows)
1326
+
1327
+ return listing.scan(fetch, query)
1328
+
1329
+
1330
+ #
1331
+ # Multipart uploads
1332
+ #
1333
+ @_write
1334
+ def create_upload(
1335
+ connection: sqlite3.Connection,
1336
+ upload: UploadRef,
1337
+ headers: Mapping[str, str],
1338
+ checksum: ChecksumPlan | None = None,
1339
+ extras: UploadExtras = NO_EXTRAS,
1340
+ ) -> None:
1341
+ _require_bucket(connection, upload.bucket)
1342
+ connection.execute(
1343
+ """
1344
+ INSERT INTO uploads
1345
+ (upload_id, bucket, key, headers, created_at,
1346
+ checksum_algorithm, checksum_type, tags, public, sse_check)
1347
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1348
+ """,
1349
+ (
1350
+ upload.upload_id,
1351
+ upload.bucket,
1352
+ upload.key,
1353
+ json.dumps(headers),
1354
+ now_ms(),
1355
+ checksum and checksum.algorithm,
1356
+ checksum and checksum.checksum_type,
1357
+ _dump_tags(extras.tags),
1358
+ extras.public,
1359
+ extras.sse_check,
1360
+ ),
1361
+ )
1362
+
1363
+
1364
+ @_read
1365
+ def require_upload(connection: sqlite3.Connection, upload: UploadRef) -> Upload:
1366
+ return _load_upload(connection, upload)
1367
+
1368
+
1369
+ @_write
1370
+ def commit_part(
1371
+ connection: sqlite3.Connection,
1372
+ upload: UploadRef,
1373
+ number: int,
1374
+ blob: Blob,
1375
+ checksum: Checksum | None,
1376
+ ) -> None:
1377
+ """Record a part. Uploading the same number again replaces the earlier one."""
1378
+ _load_upload(connection, upload)
1379
+ _queue_for_gc(
1380
+ connection,
1381
+ "SELECT blob_id FROM upload_parts WHERE upload_id = ? AND part_number = ?",
1382
+ (upload.upload_id, number),
1383
+ )
1384
+ _insert_blobs(connection, (blob,))
1385
+ connection.execute(
1386
+ """
1387
+ INSERT OR REPLACE INTO upload_parts
1388
+ (upload_id, part_number, blob_id, checksum, modified_at)
1389
+ VALUES (?, ?, ?, ?, ?)
1390
+ """,
1391
+ (upload.upload_id, number, blob.blob_id, _dump_checksum(checksum), now_ms()),
1392
+ )
1393
+
1394
+
1395
+ @_write
1396
+ def complete_upload(
1397
+ connection: sqlite3.Connection,
1398
+ upload: UploadRef,
1399
+ request: CompletionRequest,
1400
+ condition: WriteCondition,
1401
+ ) -> CompletedUpload:
1402
+ """Turn parts into an object in one transaction, without touching any data.
1403
+
1404
+ A retry after a lost reply must succeed rather than report NoSuchUpload for an
1405
+ upload that did complete, so the result stays available for a while. A claim
1406
+ that turns out false rolls everything back, so the client can complete it again.
1407
+ """
1408
+ previous = connection.execute(
1409
+ """
1410
+ SELECT etag, checksum, version_id FROM completed_uploads
1411
+ WHERE upload_id = ? AND bucket = ? AND key = ?
1412
+ """,
1413
+ (upload.upload_id, upload.bucket, upload.key),
1414
+ ).fetchone()
1415
+ if previous is not None:
1416
+ completed = CompletedUpload(
1417
+ previous[0], _load_checksum(previous[1]), previous[2]
1418
+ )
1419
+ multipart.check_checksum_claims(request, completed.checksum)
1420
+ return completed
1421
+
1422
+ started = _load_upload(connection, upload)
1423
+ _check_write(connection, upload.bucket, upload.key, condition)
1424
+ parts = _load_parts(connection, upload.upload_id, 0, _NO_LIMIT)
1425
+ assembly = multipart.assemble(
1426
+ request.parts,
1427
+ {part.number: part for part in parts},
1428
+ # Encrypted parts take a little more room than the bytes they hold.
1429
+ sse.plain_size if started.sse_check else None,
1430
+ )
1431
+ checksum = multipart.object_checksum(started.checksum, assembly.parts)
1432
+ multipart.check_checksum_claims(request, checksum)
1433
+ multipart.check_size_claim(request, assembly.size)
1434
+
1435
+ record = ObjectRecord(
1436
+ upload.key,
1437
+ assembly.size,
1438
+ assembly.etag,
1439
+ now_ms(),
1440
+ started.headers,
1441
+ checksum,
1442
+ assembly.blobs,
1443
+ tags=started.tags,
1444
+ public=started.public,
1445
+ sse_check=started.sse_check,
1446
+ )
1447
+ connection.executemany(
1448
+ "INSERT INTO blob_gc (blob_id, due_at) VALUES (?, ?)",
1449
+ [(blob_id, _gc_due_at()) for blob_id in assembly.unused_blob_ids],
1450
+ )
1451
+ connection.execute("DELETE FROM uploads WHERE upload_id = ?", (upload.upload_id,))
1452
+ version_id = _store_as_current(connection, upload.bucket, record)
1453
+ connection.execute(
1454
+ """
1455
+ INSERT INTO completed_uploads
1456
+ (upload_id, bucket, key, etag, completed_at, checksum, version_id)
1457
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1458
+ """,
1459
+ (
1460
+ upload.upload_id,
1461
+ upload.bucket,
1462
+ upload.key,
1463
+ record.etag,
1464
+ record.modified_at,
1465
+ _dump_checksum(checksum),
1466
+ version_id,
1467
+ ),
1468
+ )
1469
+ return CompletedUpload(record.etag, checksum, version_id)
1470
+
1471
+
1472
+ @_write
1473
+ def abort_upload(connection: sqlite3.Connection, upload: UploadRef) -> None:
1474
+ _load_upload(connection, upload)
1475
+ _remove_upload(connection, upload.upload_id)
1476
+
1477
+
1478
+ def _remove_upload(connection: sqlite3.Connection, upload_id: str) -> None:
1479
+ _queue_for_gc(
1480
+ connection,
1481
+ "SELECT blob_id FROM upload_parts WHERE upload_id = ?",
1482
+ (upload_id,),
1483
+ )
1484
+ connection.execute("DELETE FROM uploads WHERE upload_id = ?", (upload_id,))
1485
+
1486
+
1487
+ @_read
1488
+ def list_parts(
1489
+ connection: sqlite3.Connection, upload: UploadRef, after_part: int, limit: int
1490
+ ) -> list[Part]:
1491
+ _load_upload(connection, upload)
1492
+ return _load_parts(connection, upload.upload_id, after_part, limit)
1493
+
1494
+
1495
+ @_read
1496
+ def list_uploads(
1497
+ connection: sqlite3.Connection,
1498
+ bucket: str,
1499
+ prefix: str,
1500
+ after: tuple[str, str],
1501
+ limit: int,
1502
+ ) -> list[Upload]:
1503
+ """List uploads in (key, upload ID) order, after the given position."""
1504
+ _require_bucket(connection, bucket)
1505
+ upper = listing.key_after_prefix(prefix) if prefix else None
1506
+ rows = connection.execute(
1507
+ """
1508
+ SELECT upload_id, bucket, key, headers, created_at,
1509
+ checksum_algorithm, checksum_type
1510
+ FROM uploads
1511
+ WHERE bucket = ? AND key >= ? AND (? IS NULL OR key < ?)
1512
+ AND (key, upload_id) > (?, ?)
1513
+ ORDER BY key, upload_id LIMIT ?
1514
+ """,
1515
+ (bucket, prefix, upper, upper, *after, limit),
1516
+ )
1517
+ return [
1518
+ Upload(
1519
+ upload_id,
1520
+ row_bucket,
1521
+ key,
1522
+ json.loads(headers),
1523
+ created_at,
1524
+ _load_plan(algorithm, checksum_type),
1525
+ )
1526
+ for (
1527
+ upload_id,
1528
+ row_bucket,
1529
+ key,
1530
+ headers,
1531
+ created_at,
1532
+ algorithm,
1533
+ checksum_type,
1534
+ ) in rows
1535
+ ]
1536
+
1537
+
1538
+ #
1539
+ # Blob collection and consistency checks
1540
+ #
1541
+ @_read
1542
+ def blobs_due_for_collection(
1543
+ connection: sqlite3.Connection, now: int, limit: int
1544
+ ) -> dict[str, tuple[int, ...]]:
1545
+ """Each blob due, in order, with the volumes it is on."""
1546
+ rows = connection.execute(
1547
+ """
1548
+ SELECT due.blob_id,
1549
+ (SELECT group_concat(here.volume_id) FROM blob_locations AS here
1550
+ WHERE here.blob_id = due.blob_id)
1551
+ FROM blob_gc AS due
1552
+ WHERE due.due_at <= ?
1553
+ ORDER BY due.due_at LIMIT ?
1554
+ """,
1555
+ (now, limit),
1556
+ )
1557
+ return {blob_id: _load_volume_ids(volume_ids) for blob_id, volume_ids in rows}
1558
+
1559
+
1560
+ @_write
1561
+ def forget_completed_uploads(connection: sqlite3.Connection, before: int) -> None:
1562
+ connection.execute(
1563
+ "DELETE FROM completed_uploads WHERE completed_at < ?", (before,)
1564
+ )
1565
+
1566
+
1567
+ @_write
1568
+ def forget_blobs(connection: sqlite3.Connection, blob_ids: Sequence[str]) -> None:
1569
+ parameters = [(blob_id,) for blob_id in blob_ids]
1570
+ connection.executemany("DELETE FROM blob_gc WHERE blob_id = ?", parameters)
1571
+ connection.executemany("DELETE FROM blobs WHERE blob_id = ?", parameters)
1572
+
1573
+
1574
+ @_read
1575
+ def all_blobs(connection: sqlite3.Connection) -> dict[str, KnownBlob]:
1576
+ # ponytail: loads every ID into memory; stream with a sorted merge if fsck
1577
+ # has to run on stores too large for that.
1578
+ rows = connection.execute(
1579
+ """
1580
+ SELECT blobs.blob_id, blobs.size, blob_gc.blob_id IS NOT NULL,
1581
+ (SELECT group_concat(here.volume_id) FROM blob_locations AS here
1582
+ WHERE here.blob_id = blobs.blob_id)
1583
+ FROM blobs LEFT JOIN blob_gc USING (blob_id)
1584
+ """
1585
+ )
1586
+ return {
1587
+ blob_id: KnownBlob(size, bool(queued), _load_volume_ids(volume_ids))
1588
+ for blob_id, size, queued, volume_ids in rows
1589
+ }
1590
+
1591
+
1592
+ #
1593
+ # Volumes
1594
+ #
1595
+ @_write
1596
+ def add_volume(connection: sqlite3.Connection, identity: str, path: str) -> int:
1597
+ row = connection.execute(
1598
+ "INSERT INTO volumes (identity, path) VALUES (?, ?) RETURNING volume_id",
1599
+ (identity, path),
1600
+ ).fetchone()
1601
+ return row[0]
1602
+
1603
+
1604
+ @_read
1605
+ def current_volumes(
1606
+ connection: sqlite3.Connection,
1607
+ ) -> tuple[tuple[VolumeRecord, ...], int]:
1608
+ """Every volume and the number of copies, for workers to follow changes."""
1609
+ return _load_volumes(connection), _copies(connection)
1610
+
1611
+
1612
+ @_write
1613
+ def set_copies(connection: sqlite3.Connection, copies: int) -> None:
1614
+ connection.execute("UPDATE settings SET copies = ?", (copies,))
1615
+ connection.execute(
1616
+ """
1617
+ INSERT OR IGNORE INTO repair_queue (blob_id, due_at)
1618
+ SELECT location.blob_id, 0 FROM blob_locations AS location
1619
+ WHERE NOT EXISTS (SELECT 1 FROM blob_gc WHERE blob_id = location.blob_id)
1620
+ GROUP BY location.blob_id HAVING count(*) < ?
1621
+ """,
1622
+ (copies,),
1623
+ )
1624
+
1625
+
1626
+ @_write
1627
+ def drain_volume(connection: sqlite3.Connection, volume_id: int) -> None:
1628
+ """Stop using a volume, and queue what it holds for moving elsewhere."""
1629
+ copies = _copies(connection)
1630
+ others = connection.execute(
1631
+ "SELECT count(*) FROM volumes WHERE draining = 0 AND volume_id != ?",
1632
+ (volume_id,),
1633
+ ).fetchone()[0]
1634
+ if others < copies:
1635
+ message = (
1636
+ f"{others} other volumes can't hold {copies} copies: "
1637
+ "add a volume or lower the copies first"
1638
+ )
1639
+ raise VolumeChangeError(message)
1640
+ if not _set_draining(connection, volume_id):
1641
+ message = f"no volume {volume_id}"
1642
+ raise VolumeChangeError(message)
1643
+ _queue_blobs_on_volume(connection, volume_id)
1644
+
1645
+
1646
+ def _set_draining(connection: sqlite3.Connection, volume_id: int) -> bool:
1647
+ updated = connection.execute(
1648
+ "UPDATE volumes SET draining = 1 WHERE volume_id = ?", (volume_id,)
1649
+ )
1650
+ return updated.rowcount == 1
1651
+
1652
+
1653
+ def _queue_blobs_on_volume(connection: sqlite3.Connection, volume_id: int) -> None:
1654
+ connection.execute(
1655
+ """
1656
+ INSERT OR IGNORE INTO repair_queue (blob_id, due_at)
1657
+ SELECT location.blob_id, 0 FROM blob_locations AS location
1658
+ WHERE location.volume_id = ?
1659
+ AND NOT EXISTS (SELECT 1 FROM blob_gc WHERE blob_id = location.blob_id)
1660
+ """,
1661
+ (volume_id,),
1662
+ )
1663
+
1664
+
1665
+ @_write
1666
+ def forget_volume(
1667
+ connection: sqlite3.Connection,
1668
+ volume_id: int,
1669
+ *,
1670
+ disk_lost: bool = False,
1671
+ accept_loss: bool = False,
1672
+ ) -> None:
1673
+ """Remove a volume that is empty, or whose disk is gone for good."""
1674
+ row = connection.execute(
1675
+ "SELECT path FROM volumes WHERE volume_id = ?", (volume_id,)
1676
+ ).fetchone()
1677
+ if row is None:
1678
+ message = f"no volume {volume_id}"
1679
+ raise VolumeChangeError(message)
1680
+ if row[0] == ".":
1681
+ message = "the data directory stays a volume; drain it to empty it"
1682
+ raise VolumeChangeError(message)
1683
+ held = connection.execute(
1684
+ "SELECT count(*) FROM blob_locations WHERE volume_id = ?", (volume_id,)
1685
+ ).fetchone()[0]
1686
+ if held and not disk_lost:
1687
+ message = (
1688
+ f"volume {volume_id} holds {held} blobs: drain it, "
1689
+ "or say the disk is gone with --disk-lost"
1690
+ )
1691
+ raise VolumeChangeError(message)
1692
+ only_copies = connection.execute(
1693
+ """
1694
+ SELECT count(*) FROM blob_locations AS here
1695
+ WHERE here.volume_id = ?
1696
+ AND NOT EXISTS (SELECT 1 FROM blob_gc WHERE blob_id = here.blob_id)
1697
+ AND NOT EXISTS (
1698
+ SELECT 1 FROM blob_locations AS other
1699
+ WHERE other.blob_id = here.blob_id AND other.volume_id != here.volume_id
1700
+ )
1701
+ """,
1702
+ (volume_id,),
1703
+ ).fetchone()[0]
1704
+ if only_copies and not accept_loss:
1705
+ message = (
1706
+ f"{only_copies} blobs have no other copy, so their objects would be "
1707
+ "lost: if the disk really is gone, say so with --accept-loss"
1708
+ )
1709
+ raise VolumeChangeError(message)
1710
+ _queue_blobs_on_volume(connection, volume_id)
1711
+ connection.execute("DELETE FROM blob_locations WHERE volume_id = ?", (volume_id,))
1712
+ connection.execute("DELETE FROM volumes WHERE volume_id = ?", (volume_id,))
1713
+
1714
+
1715
+ #
1716
+ # Repair
1717
+ #
1718
+ @_read
1719
+ def repairs_due(connection: sqlite3.Connection, now: int) -> bool:
1720
+ """Whether a claim could find anything, without queueing for the writer lock."""
1721
+ row = connection.execute(
1722
+ "SELECT 1 FROM repair_queue WHERE due_at <= ? LIMIT 1", (now,)
1723
+ ).fetchone()
1724
+ return row is not None
1725
+
1726
+
1727
+ @_read
1728
+ def repairs_queued(connection: sqlite3.Connection) -> int:
1729
+ return connection.execute("SELECT count(*) FROM repair_queue").fetchone()[0]
1730
+
1731
+
1732
+ @_read
1733
+ def blobs_awaiting_collection(connection: sqlite3.Connection) -> int:
1734
+ return connection.execute("SELECT count(*) FROM blob_gc").fetchone()[0]
1735
+
1736
+
1737
+ @_write
1738
+ def claim_repairs(
1739
+ connection: sqlite3.Connection, now: int, limit: int, lease_ms: int
1740
+ ) -> list[RepairWork]:
1741
+ """Take a batch for a while: other workers skip it, and a crash gives it back."""
1742
+ rows = connection.execute(
1743
+ """
1744
+ SELECT blob.blob_id, blob.size, blob.md5,
1745
+ (SELECT group_concat(here.volume_id) FROM blob_locations AS here
1746
+ WHERE here.blob_id = blob.blob_id),
1747
+ EXISTS (SELECT 1 FROM blob_gc WHERE blob_id = blob.blob_id)
1748
+ FROM repair_queue AS queued
1749
+ JOIN blobs AS blob ON blob.blob_id = queued.blob_id
1750
+ WHERE queued.due_at <= ?
1751
+ ORDER BY queued.due_at LIMIT ?
1752
+ """,
1753
+ (now, limit),
1754
+ ).fetchall()
1755
+ connection.executemany(
1756
+ "DELETE FROM repair_queue WHERE blob_id = ?",
1757
+ [(row[0],) for row in rows if row[4]],
1758
+ )
1759
+ connection.executemany(
1760
+ "UPDATE repair_queue SET due_at = ? WHERE blob_id = ?",
1761
+ [(now + lease_ms, row[0]) for row in rows if not row[4]],
1762
+ )
1763
+ copies = _copies(connection)
1764
+ draining = _draining_volumes(connection)
1765
+ return [
1766
+ RepairWork(
1767
+ Blob(blob_id, size, md5, _load_volume_ids(volume_ids)), copies, draining
1768
+ )
1769
+ for blob_id, size, md5, volume_ids, queued_for_collection in rows
1770
+ if not queued_for_collection
1771
+ ]
1772
+
1773
+
1774
+ @_write
1775
+ def add_location(connection: sqlite3.Connection, blob_id: str, volume_id: int) -> bool:
1776
+ """Record a new copy. False if the blob has gone, and the copy with it."""
1777
+ exists = connection.execute(
1778
+ "SELECT 1 FROM blobs WHERE blob_id = ?", (blob_id,)
1779
+ ).fetchone()
1780
+ if exists is None:
1781
+ return False
1782
+ connection.execute(
1783
+ "INSERT OR IGNORE INTO blob_locations (blob_id, volume_id) VALUES (?, ?)",
1784
+ (blob_id, volume_id),
1785
+ )
1786
+ return True
1787
+
1788
+
1789
+ @_write
1790
+ def postpone_repair(connection: sqlite3.Connection, blob_id: str, due_at: int) -> None:
1791
+ connection.execute(
1792
+ "UPDATE repair_queue SET due_at = ? WHERE blob_id = ?", (due_at, blob_id)
1793
+ )
1794
+
1795
+
1796
+ @_write
1797
+ def finish_repair(connection: sqlite3.Connection, blob_id: str) -> bool:
1798
+ """Take a blob off the queue, if it now has what it needs.
1799
+
1800
+ Checked here and not by the caller, whose view may be old: scrub may have removed
1801
+ a copy since, and queueing the blob again did nothing while it was still queued.
1802
+ """
1803
+ held = connection.execute(
1804
+ """
1805
+ SELECT count(*), coalesce(sum(volume.draining), 0)
1806
+ FROM blob_locations AS location JOIN volumes AS volume USING (volume_id)
1807
+ WHERE location.blob_id = ?
1808
+ """,
1809
+ (blob_id,),
1810
+ ).fetchone()
1811
+ copies, draining = held
1812
+ if copies - draining < _copies(connection) or draining:
1813
+ return False
1814
+ connection.execute("DELETE FROM repair_queue WHERE blob_id = ?", (blob_id,))
1815
+ return True
1816
+
1817
+
1818
+ @_read
1819
+ def other_copies(
1820
+ connection: sqlite3.Connection, blob_id: str, volume_id: int
1821
+ ) -> int | None:
1822
+ """How many other copies a blob has, or None if it is gone or on its way out."""
1823
+ row = connection.execute(
1824
+ """
1825
+ SELECT count(other.volume_id) FROM blobs AS blob
1826
+ LEFT JOIN blob_locations AS other
1827
+ ON other.blob_id = blob.blob_id AND other.volume_id != ?
1828
+ WHERE blob.blob_id = ?
1829
+ AND NOT EXISTS (SELECT 1 FROM blob_gc WHERE blob_id = blob.blob_id)
1830
+ GROUP BY blob.blob_id
1831
+ """,
1832
+ (volume_id, blob_id),
1833
+ ).fetchone()
1834
+ return None if row is None else row[0]
1835
+
1836
+
1837
+ @_write
1838
+ def remove_location(
1839
+ connection: sqlite3.Connection, blob_id: str, volume_id: int
1840
+ ) -> None:
1841
+ """Forget a copy whose file has gone, and queue the blob if that leaves it short."""
1842
+ connection.execute(
1843
+ "DELETE FROM blob_locations WHERE blob_id = ? AND volume_id = ?",
1844
+ (blob_id, volume_id),
1845
+ )
1846
+ connection.execute(
1847
+ """
1848
+ INSERT OR IGNORE INTO repair_queue (blob_id, due_at)
1849
+ SELECT ?, 0 FROM settings
1850
+ WHERE copies > (SELECT count(*) FROM blob_locations WHERE blob_id = ?)
1851
+ AND NOT EXISTS (SELECT 1 FROM blob_gc WHERE blob_id = ?)
1852
+ AND EXISTS (SELECT 1 FROM blobs WHERE blob_id = ?)
1853
+ """,
1854
+ (blob_id, blob_id, blob_id, blob_id),
1855
+ )
1856
+
1857
+
1858
+ #
1859
+ # Scrub
1860
+ #
1861
+ @_read
1862
+ def scrub_due_at(connection: sqlite3.Connection) -> int:
1863
+ row = connection.execute("SELECT scrub_not_before FROM settings").fetchone()
1864
+ return row[0]
1865
+
1866
+
1867
+ @_write
1868
+ def claim_scrub_batch(
1869
+ connection: sqlite3.Connection, now: int, limit: int, bytes_per_second: int
1870
+ ) -> list[Blob]:
1871
+ """Take the next blobs to check, and hold off every worker for what they cost.
1872
+
1873
+ Keeping the cursor and the pace here spreads one store-wide rate over the workers,
1874
+ with no leader, and carries on from where it was after a restart.
1875
+ """
1876
+ cursor, not_before = connection.execute(
1877
+ "SELECT scrub_cursor, scrub_not_before FROM settings"
1878
+ ).fetchone()
1879
+ if now < not_before:
1880
+ return []
1881
+ batch = _blobs_to_scrub(connection, cursor, limit)
1882
+ cost = sum(blob.size + _SCRUB_MIN_BYTES_PER_BLOB for blob in batch)
1883
+ pause_ms = cost * _MILLISECONDS // bytes_per_second
1884
+ finished_a_pass = len(batch) < limit
1885
+ connection.execute(
1886
+ "UPDATE settings SET scrub_cursor = ?, scrub_not_before = ?",
1887
+ (
1888
+ "" if finished_a_pass else batch[-1].blob_id,
1889
+ now + (SCRUB_REST_MS if finished_a_pass else pause_ms),
1890
+ ),
1891
+ )
1892
+ return batch
1893
+
1894
+
1895
+ @_read
1896
+ def blobs_to_scrub(
1897
+ connection: sqlite3.Connection, after: str, limit: int
1898
+ ) -> list[Blob]:
1899
+ return _blobs_to_scrub(connection, after, limit)
1900
+
1901
+
1902
+ def _blobs_to_scrub(
1903
+ connection: sqlite3.Connection, after: str, limit: int
1904
+ ) -> list[Blob]:
1905
+ rows = connection.execute(
1906
+ """
1907
+ SELECT blob.blob_id, blob.size, blob.md5,
1908
+ (SELECT group_concat(here.volume_id) FROM blob_locations AS here
1909
+ WHERE here.blob_id = blob.blob_id)
1910
+ FROM blobs AS blob
1911
+ WHERE blob.blob_id > ?
1912
+ AND NOT EXISTS (SELECT 1 FROM blob_gc WHERE blob_id = blob.blob_id)
1913
+ ORDER BY blob.blob_id LIMIT ?
1914
+ """,
1915
+ (after, limit),
1916
+ )
1917
+ return [
1918
+ Blob(blob_id, size, md5, _load_volume_ids(volume_ids))
1919
+ for blob_id, size, md5, volume_ids in rows
1920
+ ]
1921
+
1922
+
1923
+ @_read
1924
+ def blobs_per_volume(connection: sqlite3.Connection) -> dict[int, int]:
1925
+ rows = connection.execute(
1926
+ "SELECT volume_id, count(*) FROM blob_locations GROUP BY volume_id"
1927
+ )
1928
+ return dict(rows.fetchall())
1929
+
1930
+
1931
+ #
1932
+ # Helpers: pieces of a transaction, called only from the functions above.
1933
+ #
1934
+ def _require_bucket(connection: sqlite3.Connection, bucket: str) -> None:
1935
+ row = connection.execute(
1936
+ "SELECT 1 FROM buckets WHERE name = ?", (bucket,)
1937
+ ).fetchone()
1938
+ if row is None:
1939
+ raise S3Error(errors.NO_SUCH_BUCKET)
1940
+
1941
+
1942
+ def _check_write(
1943
+ connection: sqlite3.Connection,
1944
+ bucket: str,
1945
+ key: str,
1946
+ condition: WriteCondition,
1947
+ ) -> None:
1948
+ _require_bucket(connection, bucket)
1949
+ row = connection.execute(
1950
+ "SELECT etag FROM objects WHERE bucket = ? AND key = ?", (bucket, key)
1951
+ ).fetchone()
1952
+ conditions.check_write_condition(condition, row[0] if row else None)
1953
+
1954
+
1955
+ def _insert_blobs(connection: sqlite3.Connection, blobs: Iterable[Blob]) -> None:
1956
+ connection.executemany(
1957
+ "INSERT INTO blobs (blob_id, size, md5) VALUES (?, ?, ?)",
1958
+ [(blob.blob_id, blob.size, blob.md5) for blob in blobs],
1959
+ )
1960
+ connection.executemany(
1961
+ "INSERT INTO blob_locations (blob_id, volume_id) VALUES (?, ?)",
1962
+ [(blob.blob_id, volume_id) for blob in blobs for volume_id in blob.volume_ids],
1963
+ )
1964
+ copies = _copies(connection)
1965
+ draining = _draining_volumes(connection)
1966
+ connection.executemany(
1967
+ "INSERT OR IGNORE INTO repair_queue (blob_id, due_at) VALUES (?, 0)",
1968
+ [
1969
+ (blob.blob_id,)
1970
+ for blob in blobs
1971
+ if len(blob.volume_ids) < copies or draining.intersection(blob.volume_ids)
1972
+ ],
1973
+ )
1974
+
1975
+
1976
+ def _copies(connection: sqlite3.Connection) -> int:
1977
+ return connection.execute("SELECT copies FROM settings").fetchone()[0]
1978
+
1979
+
1980
+ def _draining_volumes(connection: sqlite3.Connection) -> frozenset[int]:
1981
+ rows = connection.execute("SELECT volume_id FROM volumes WHERE draining = 1")
1982
+ return frozenset(row[0] for row in rows)
1983
+
1984
+
1985
+ def _insert_object(
1986
+ connection: sqlite3.Connection, bucket: str, record: ObjectRecord
1987
+ ) -> None:
1988
+ connection.execute(
1989
+ """
1990
+ INSERT INTO objects
1991
+ (bucket, key, version_id,
1992
+ size, etag, modified_at, headers, checksum, tags, public, sse_check)
1993
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1994
+ """,
1995
+ (
1996
+ bucket,
1997
+ record.key,
1998
+ record.version_id,
1999
+ record.size,
2000
+ record.etag,
2001
+ record.modified_at,
2002
+ json.dumps(record.headers),
2003
+ _dump_checksum(record.checksum),
2004
+ _dump_tags(record.tags),
2005
+ record.public,
2006
+ record.sse_check,
2007
+ ),
2008
+ )
2009
+ connection.executemany(
2010
+ """
2011
+ INSERT INTO object_blobs (bucket, key, position, blob_id)
2012
+ VALUES (?, ?, ?, ?)
2013
+ """,
2014
+ [
2015
+ (bucket, record.key, position, blob.blob_id)
2016
+ for position, blob in enumerate(record.blobs)
2017
+ ],
2018
+ )
2019
+
2020
+
2021
+ def _remove_object(connection: sqlite3.Connection, bucket: str, key: str) -> None:
2022
+ """Remove the current object, if any, and queue its bytes for collection."""
2023
+ _queue_for_gc(
2024
+ connection,
2025
+ "SELECT blob_id FROM object_blobs WHERE bucket = ? AND key = ?",
2026
+ (bucket, key),
2027
+ )
2028
+ connection.execute(
2029
+ "DELETE FROM objects WHERE bucket = ? AND key = ?", (bucket, key)
2030
+ )
2031
+
2032
+
2033
+ def _versioning(connection: sqlite3.Connection, bucket: str) -> Versioning | None:
2034
+ row = connection.execute(
2035
+ "SELECT versioning FROM buckets WHERE name = ?", (bucket,)
2036
+ ).fetchone()
2037
+ return Versioning(row[0]) if row[0] else None
2038
+
2039
+
2040
+ def _store_as_current(
2041
+ connection: sqlite3.Connection, bucket: str, record: ObjectRecord
2042
+ ) -> str | None:
2043
+ """Put a new version in place, keeping or discarding the old as the bucket says."""
2044
+ state = _versioning(connection, bucket)
2045
+ if state is None:
2046
+ _remove_object(connection, bucket, record.key)
2047
+ version_id = None
2048
+ elif state is Versioning.ENABLED:
2049
+ _archive_current(connection, bucket, record.key)
2050
+ version_id = _new_version_id()
2051
+ else:
2052
+ _clear_null_version(connection, bucket, record.key)
2053
+ version_id = NULL_VERSION_ID
2054
+ _insert_object(
2055
+ connection,
2056
+ bucket,
2057
+ replace(record, version_id=version_id or NULL_VERSION_ID),
2058
+ )
2059
+ return version_id
2060
+
2061
+
2062
+ def _new_version_id() -> str:
2063
+ return secrets.token_urlsafe(_VERSION_ID_BYTES)
2064
+
2065
+
2066
+ def _delete_current(
2067
+ connection: sqlite3.Connection, bucket: str, key: str
2068
+ ) -> DeleteOutcome:
2069
+ """Delete a key: for good if unversioned, or else behind a new delete marker."""
2070
+ state = _versioning(connection, bucket)
2071
+ if state is None:
2072
+ _remove_object(connection, bucket, key)
2073
+ return DeleteOutcome(key)
2074
+ if state is Versioning.ENABLED:
2075
+ _archive_current(connection, bucket, key)
2076
+ version_id = _new_version_id()
2077
+ else:
2078
+ _clear_null_version(connection, bucket, key)
2079
+ version_id = NULL_VERSION_ID
2080
+ connection.execute(
2081
+ """
2082
+ INSERT INTO object_versions
2083
+ (bucket, key, version_id, rank, is_delete_marker,
2084
+ size, etag, modified_at, headers, checksum)
2085
+ VALUES (?, ?, ?, ?, 1, 0, '', ?, '{}', NULL)
2086
+ """,
2087
+ (bucket, key, version_id, _next_rank(connection, bucket, key), now_ms()),
2088
+ )
2089
+ return DeleteOutcome(key, version_id, is_delete_marker=True)
2090
+
2091
+
2092
+ def _next_rank(connection: sqlite3.Connection, bucket: str, key: str) -> int:
2093
+ row = connection.execute(
2094
+ "SELECT min(rank) FROM object_versions WHERE bucket = ? AND key = ?",
2095
+ (bucket, key),
2096
+ ).fetchone()
2097
+ return -1 if row[0] is None else row[0] - 1
2098
+
2099
+
2100
+ def _archive_current(connection: sqlite3.Connection, bucket: str, key: str) -> None:
2101
+ """Make the current version an older one. Its blobs stay exactly where they are."""
2102
+ rank = _next_rank(connection, bucket, key)
2103
+ copied = connection.execute(
2104
+ """
2105
+ INSERT INTO object_versions
2106
+ (bucket, key, version_id, rank, is_delete_marker,
2107
+ size, etag, modified_at, headers, checksum, tags, public, sse_check)
2108
+ SELECT bucket, key, version_id, ?, 0,
2109
+ size, etag, modified_at, headers, checksum, tags, public, sse_check
2110
+ FROM objects WHERE bucket = ? AND key = ?
2111
+ """,
2112
+ (rank, bucket, key),
2113
+ )
2114
+ if copied.rowcount == 0:
2115
+ return
2116
+ connection.execute(
2117
+ """
2118
+ INSERT INTO version_blobs (bucket, key, version_id, position, blob_id)
2119
+ SELECT link.bucket, link.key, objects.version_id, link.position, link.blob_id
2120
+ FROM object_blobs AS link
2121
+ JOIN objects USING (bucket, key)
2122
+ WHERE link.bucket = ? AND link.key = ?
2123
+ """,
2124
+ (bucket, key),
2125
+ )
2126
+ connection.execute(
2127
+ "DELETE FROM objects WHERE bucket = ? AND key = ?", (bucket, key)
2128
+ )
2129
+
2130
+
2131
+ def _clear_null_version(connection: sqlite3.Connection, bucket: str, key: str) -> None:
2132
+ """Make way for a new version named "null", which replaces any before it."""
2133
+ current = connection.execute(
2134
+ "SELECT version_id FROM objects WHERE bucket = ? AND key = ?", (bucket, key)
2135
+ ).fetchone()
2136
+ if current is not None and current[0] == NULL_VERSION_ID:
2137
+ _remove_object(connection, bucket, key)
2138
+ else:
2139
+ _archive_current(connection, bucket, key)
2140
+ _remove_older_version(connection, bucket, key, NULL_VERSION_ID)
2141
+
2142
+
2143
+ def _remove_older_version(
2144
+ connection: sqlite3.Connection, bucket: str, key: str, version_id: str
2145
+ ) -> bool:
2146
+ """Remove an older version or marker, if any, and queue its bytes for collection."""
2147
+ _queue_for_gc(
2148
+ connection,
2149
+ """
2150
+ SELECT blob_id FROM version_blobs
2151
+ WHERE bucket = ? AND key = ? AND version_id = ?
2152
+ """,
2153
+ (bucket, key, version_id),
2154
+ )
2155
+ removed = connection.execute(
2156
+ """
2157
+ DELETE FROM object_versions WHERE bucket = ? AND key = ? AND version_id = ?
2158
+ """,
2159
+ (bucket, key, version_id),
2160
+ )
2161
+ return removed.rowcount > 0
2162
+
2163
+
2164
+ def _erase_version(
2165
+ connection: sqlite3.Connection, bucket: str, key: str, version_id: str
2166
+ ) -> DeleteOutcome:
2167
+ """Remove one version for good, so that the next newest may become current."""
2168
+ current = connection.execute(
2169
+ "SELECT version_id FROM objects WHERE bucket = ? AND key = ?", (bucket, key)
2170
+ ).fetchone()
2171
+ if current is not None and current[0] == version_id:
2172
+ _remove_object(connection, bucket, key)
2173
+ _promote_newest(connection, bucket, key)
2174
+ return DeleteOutcome(key, version_id)
2175
+ row = connection.execute(
2176
+ """
2177
+ SELECT is_delete_marker FROM object_versions
2178
+ WHERE bucket = ? AND key = ? AND version_id = ?
2179
+ """,
2180
+ (bucket, key, version_id),
2181
+ ).fetchone()
2182
+ if row is None:
2183
+ return DeleteOutcome(key, version_id) # As with a key, gone is gone.
2184
+ _remove_older_version(connection, bucket, key, version_id)
2185
+ if current is None:
2186
+ _promote_newest(connection, bucket, key)
2187
+ return DeleteOutcome(key, version_id, is_delete_marker=bool(row[0]))
2188
+
2189
+
2190
+ def _promote_newest(connection: sqlite3.Connection, bucket: str, key: str) -> None:
2191
+ """With no current version, the newest older one takes over, unless a marker."""
2192
+ newest = connection.execute(
2193
+ """
2194
+ SELECT version_id, is_delete_marker FROM object_versions
2195
+ WHERE bucket = ? AND key = ? ORDER BY rank LIMIT 1
2196
+ """,
2197
+ (bucket, key),
2198
+ ).fetchone()
2199
+ if newest is None or newest[1]:
2200
+ return
2201
+ version_id = newest[0]
2202
+ connection.execute(
2203
+ """
2204
+ INSERT INTO objects
2205
+ (bucket, key, version_id,
2206
+ size, etag, modified_at, headers, checksum, tags, public, sse_check)
2207
+ SELECT bucket, key, version_id,
2208
+ size, etag, modified_at, headers, checksum, tags, public, sse_check
2209
+ FROM object_versions WHERE bucket = ? AND key = ? AND version_id = ?
2210
+ """,
2211
+ (bucket, key, version_id),
2212
+ )
2213
+ connection.execute(
2214
+ """
2215
+ INSERT INTO object_blobs (bucket, key, position, blob_id)
2216
+ SELECT bucket, key, position, blob_id FROM version_blobs
2217
+ WHERE bucket = ? AND key = ? AND version_id = ?
2218
+ """,
2219
+ (bucket, key, version_id),
2220
+ )
2221
+ connection.execute(
2222
+ """
2223
+ DELETE FROM object_versions WHERE bucket = ? AND key = ? AND version_id = ?
2224
+ """,
2225
+ (bucket, key, version_id),
2226
+ )
2227
+
2228
+
2229
+ def _queue_for_gc(
2230
+ connection: sqlite3.Connection, select_blob_ids: str, parameters: tuple[object, ...]
2231
+ ) -> None:
2232
+ rows = connection.execute(select_blob_ids, parameters).fetchall()
2233
+ due_at = _gc_due_at()
2234
+ connection.executemany(
2235
+ "INSERT INTO blob_gc (blob_id, due_at) VALUES (?, ?)",
2236
+ [(row[0], due_at) for row in rows],
2237
+ )
2238
+
2239
+
2240
+ def _gc_due_at() -> int:
2241
+ return now_ms() + GC_GRACE_MS
2242
+
2243
+
2244
+ def _load_upload(connection: sqlite3.Connection, upload: UploadRef) -> Upload:
2245
+ row = connection.execute(
2246
+ """
2247
+ SELECT headers, created_at, checksum_algorithm, checksum_type, tags, public,
2248
+ sse_check
2249
+ FROM uploads
2250
+ WHERE upload_id = ? AND bucket = ? AND key = ?
2251
+ """,
2252
+ (upload.upload_id, upload.bucket, upload.key),
2253
+ ).fetchone()
2254
+ if row is None:
2255
+ raise S3Error(errors.NO_SUCH_UPLOAD)
2256
+ return Upload(
2257
+ upload.upload_id,
2258
+ upload.bucket,
2259
+ upload.key,
2260
+ json.loads(row[0]),
2261
+ row[1],
2262
+ _load_plan(row[2], row[3]),
2263
+ _load_tags(row[4]),
2264
+ bool(row[5]),
2265
+ row[6],
2266
+ )
2267
+
2268
+
2269
+ def _load_plan(algorithm: str | None, checksum_type: str | None) -> ChecksumPlan | None:
2270
+ if algorithm is None or checksum_type is None:
2271
+ return None
2272
+ return ChecksumPlan(algorithm, ChecksumType(checksum_type))
2273
+
2274
+
2275
+ def _load_parts(
2276
+ connection: sqlite3.Connection, upload_id: str, after_part: int, limit: int
2277
+ ) -> list[Part]:
2278
+ """Load parts in order."""
2279
+ rows = connection.execute(
2280
+ """
2281
+ SELECT part.part_number, blob.blob_id, blob.size, blob.md5,
2282
+ (SELECT group_concat(here.volume_id) FROM blob_locations AS here
2283
+ WHERE here.blob_id = blob.blob_id),
2284
+ part.checksum, part.modified_at
2285
+ FROM upload_parts AS part
2286
+ JOIN blobs AS blob ON blob.blob_id = part.blob_id
2287
+ WHERE part.upload_id = ? AND part.part_number > ?
2288
+ ORDER BY part.part_number LIMIT ?
2289
+ """,
2290
+ (upload_id, after_part, limit),
2291
+ )
2292
+ return [
2293
+ Part(
2294
+ number,
2295
+ _load_blob(blob_id, size, md5, volume_ids),
2296
+ _load_checksum(checksum),
2297
+ modified_at,
2298
+ )
2299
+ for number, blob_id, size, md5, volume_ids, checksum, modified_at in rows
2300
+ ]
2301
+
2302
+
2303
+ def _load_blob(blob_id: str, size: int, md5: str, volume_ids: str) -> Blob:
2304
+ return Blob(blob_id, size, md5, _load_volume_ids(volume_ids))
2305
+
2306
+
2307
+ def _load_volume_ids(volume_ids: str | None) -> tuple[int, ...]:
2308
+ """Parse `group_concat`, which gives NULL for a blob with no location left."""
2309
+ return tuple(map(int, volume_ids.split(","))) if volume_ids else ()
2310
+
2311
+
2312
+ def _dump_tags(tags: Mapping[str, str]) -> str | None:
2313
+ return json.dumps(tags) if tags else None
2314
+
2315
+
2316
+ def _load_tags(text: str | None) -> dict[str, str]:
2317
+ return json.loads(text) if text else {}
2318
+
2319
+
2320
+ def _dump_checksum(checksum: Checksum | None) -> str | None:
2321
+ if checksum is None:
2322
+ return None
2323
+ return json.dumps({"algorithm": checksum.algorithm, "value": checksum.value})
2324
+
2325
+
2326
+ def _load_checksum(text: str | None) -> Checksum | None:
2327
+ return None if text is None else Checksum(**json.loads(text))