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/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))
|