theblob 0.1.0__tar.gz
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.
- theblob-0.1.0/PKG-INFO +53 -0
- theblob-0.1.0/README.md +39 -0
- theblob-0.1.0/pyproject.toml +58 -0
- theblob-0.1.0/pyproject.toml.orig +57 -0
- theblob-0.1.0/src/theblob/__init__.py +1 -0
- theblob-0.1.0/src/theblob/asgi.py +238 -0
- theblob-0.1.0/src/theblob/blobstore.py +190 -0
- theblob-0.1.0/src/theblob/cli.py +211 -0
- theblob-0.1.0/src/theblob/handlers.py +523 -0
- theblob-0.1.0/src/theblob/maintenance.py +100 -0
- theblob-0.1.0/src/theblob/meta.py +723 -0
- theblob-0.1.0/src/theblob/model.py +105 -0
- theblob-0.1.0/src/theblob/s3/__init__.py +1 -0
- theblob-0.1.0/src/theblob/s3/chunked.py +175 -0
- theblob-0.1.0/src/theblob/s3/conditions.py +135 -0
- theblob-0.1.0/src/theblob/s3/errors.py +117 -0
- theblob-0.1.0/src/theblob/s3/listing.py +140 -0
- theblob-0.1.0/src/theblob/s3/multipart.py +77 -0
- theblob-0.1.0/src/theblob/s3/payload.py +257 -0
- theblob-0.1.0/src/theblob/s3/routing.py +279 -0
- theblob-0.1.0/src/theblob/s3/sigv4.py +292 -0
- theblob-0.1.0/src/theblob/s3/xml.py +400 -0
theblob-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: theblob
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An S3-compatible object store
|
|
5
|
+
Author: Stefane Fermigier
|
|
6
|
+
Author-email: Stefane Fermigier <sf@abilian.com>
|
|
7
|
+
Requires-Dist: cyclopts>=5.3.3
|
|
8
|
+
Requires-Dist: defusedxml>=0.7.1
|
|
9
|
+
Requires-Dist: httptools>=0.6.4
|
|
10
|
+
Requires-Dist: uvicorn>=0.54.0
|
|
11
|
+
Requires-Dist: uvloop>=0.21.0
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# theblob
|
|
16
|
+
|
|
17
|
+
An S3-compatible object store written in Python. It starts as one command on one machine, and is designed to grow into a redundant cluster.
|
|
18
|
+
|
|
19
|
+
This is an experiment. Phase 1 (one server, one disk) is under construction.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
uv sync
|
|
25
|
+
uv run theblob serve --data-dir ./data
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The first start creates an access key and prints it once. Use it with any S3 client:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import boto3
|
|
32
|
+
|
|
33
|
+
s3 = boto3.client(
|
|
34
|
+
"s3",
|
|
35
|
+
endpoint_url="http://127.0.0.1:9000",
|
|
36
|
+
aws_access_key_id="...",
|
|
37
|
+
aws_secret_access_key="...",
|
|
38
|
+
region_name="us-east-1",
|
|
39
|
+
)
|
|
40
|
+
s3.create_bucket(Bucket="my-bucket")
|
|
41
|
+
s3.put_object(Bucket="my-bucket", Key="hello.txt", Body=b"hello")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Other commands: `theblob key create|list|delete` and `theblob fsck`. Run `theblob serve --help` for the server options, including TLS and virtual-hosted addressing.
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
make test # the whole test pyramid
|
|
50
|
+
make lint # ruff, ty, pyrefly, mypy
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Tests are in `tests/`: `a_unit` for pure logic, `b_integration` for real SQLite and files, `c_e2e` for real S3 clients (boto3, AWS CLI, rclone, s5cmd) against a real server over HTTP and HTTPS. The rclone and s5cmd tests are skipped if those tools are not installed.
|
theblob-0.1.0/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# theblob
|
|
2
|
+
|
|
3
|
+
An S3-compatible object store written in Python. It starts as one command on one machine, and is designed to grow into a redundant cluster.
|
|
4
|
+
|
|
5
|
+
This is an experiment. Phase 1 (one server, one disk) is under construction.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
uv sync
|
|
11
|
+
uv run theblob serve --data-dir ./data
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The first start creates an access key and prints it once. Use it with any S3 client:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import boto3
|
|
18
|
+
|
|
19
|
+
s3 = boto3.client(
|
|
20
|
+
"s3",
|
|
21
|
+
endpoint_url="http://127.0.0.1:9000",
|
|
22
|
+
aws_access_key_id="...",
|
|
23
|
+
aws_secret_access_key="...",
|
|
24
|
+
region_name="us-east-1",
|
|
25
|
+
)
|
|
26
|
+
s3.create_bucket(Bucket="my-bucket")
|
|
27
|
+
s3.put_object(Bucket="my-bucket", Key="hello.txt", Body=b"hello")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Other commands: `theblob key create|list|delete` and `theblob fsck`. Run `theblob serve --help` for the server options, including TLS and virtual-hosted addressing.
|
|
31
|
+
|
|
32
|
+
## Development
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
make test # the whole test pyramid
|
|
36
|
+
make lint # ruff, ty, pyrefly, mypy
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Tests are in `tests/`: `a_unit` for pure logic, `b_integration` for real SQLite and files, `c_e2e` for real S3 clients (boto3, AWS CLI, rclone, s5cmd) against a real server over HTTP and HTTPS. The rclone and s5cmd tests are skipped if those tools are not installed.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "theblob"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "An S3-compatible object store"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"cyclopts>=5.3.3",
|
|
9
|
+
"defusedxml>=0.7.1",
|
|
10
|
+
"httptools>=0.6.4",
|
|
11
|
+
"uvicorn>=0.54.0",
|
|
12
|
+
"uvloop>=0.21.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[[project.authors]]
|
|
16
|
+
name = "Stefane Fermigier"
|
|
17
|
+
email = "sf@abilian.com"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
theblob = "theblob.cli:app"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["uv_build>=0.13.4,<0.14.0"]
|
|
24
|
+
build-backend = "uv_build"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"anyio>=4.16.0",
|
|
29
|
+
"awscli>=1.46.2",
|
|
30
|
+
"boto3>=1.43.124",
|
|
31
|
+
"httpx>=0.28.1",
|
|
32
|
+
"mypy>=2.4.1",
|
|
33
|
+
"nox>=2026.10.20",
|
|
34
|
+
"pre-commit>=4.7.0",
|
|
35
|
+
"pyrefly>=1.4.1",
|
|
36
|
+
"pytest>=9.2.0",
|
|
37
|
+
"pytest-cov>=7.1.0",
|
|
38
|
+
"pytest-timeout>=2.4.0",
|
|
39
|
+
"ruff>=0.17.3",
|
|
40
|
+
"ty>=0.0.97",
|
|
41
|
+
"types-defusedxml>=0.7.0.20260504",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
timeout = 120
|
|
47
|
+
markers = [
|
|
48
|
+
"unit: fast tests of pure logic, no I/O",
|
|
49
|
+
"integration: components against real SQLite and real files",
|
|
50
|
+
"e2e: real S3 clients against a real server process",
|
|
51
|
+
"slow: takes more than a few seconds",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.pyrefly]
|
|
55
|
+
project-includes = [
|
|
56
|
+
"**/*.py*",
|
|
57
|
+
"**/*.ipynb",
|
|
58
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "theblob"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "An S3-compatible object store"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Stefane Fermigier", email = "sf@abilian.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"cyclopts>=5.3.3",
|
|
12
|
+
"defusedxml>=0.7.1",
|
|
13
|
+
"httptools>=0.6.4",
|
|
14
|
+
"uvicorn>=0.54.0",
|
|
15
|
+
"uvloop>=0.21.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
theblob = "theblob.cli:app"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.13.4,<0.14.0"]
|
|
23
|
+
build-backend = "uv_build"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = [
|
|
27
|
+
"anyio>=4.16.0",
|
|
28
|
+
"awscli>=1.46.2",
|
|
29
|
+
"boto3>=1.43.124",
|
|
30
|
+
"httpx>=0.28.1",
|
|
31
|
+
"mypy>=2.4.1",
|
|
32
|
+
"nox>=2026.10.20",
|
|
33
|
+
"pre-commit>=4.7.0",
|
|
34
|
+
"pyrefly>=1.4.1",
|
|
35
|
+
"pytest>=9.2.0",
|
|
36
|
+
"pytest-cov>=7.1.0",
|
|
37
|
+
"pytest-timeout>=2.4.0",
|
|
38
|
+
"ruff>=0.17.3",
|
|
39
|
+
"ty>=0.0.97",
|
|
40
|
+
"types-defusedxml>=0.7.0.20260504",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
timeout = 120
|
|
46
|
+
markers = [
|
|
47
|
+
"unit: fast tests of pure logic, no I/O",
|
|
48
|
+
"integration: components against real SQLite and real files",
|
|
49
|
+
"e2e: real S3 clients against a real server process",
|
|
50
|
+
"slow: takes more than a few seconds",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.pyrefly]
|
|
54
|
+
project-includes = [
|
|
55
|
+
"**/*.py*",
|
|
56
|
+
"**/*.ipynb",
|
|
57
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""theblob: an S3-compatible object store."""
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""The ASGI application: the one place where S3 errors become HTTP responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import aclosing, suppress
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from theblob import handlers, maintenance, meta
|
|
17
|
+
from theblob.blobstore import BlobStore
|
|
18
|
+
from theblob.handlers import ClientDisconnectedError, Context, Request, Response
|
|
19
|
+
from theblob.meta import Database
|
|
20
|
+
from theblob.model import HttpRequest
|
|
21
|
+
from theblob.s3 import errors, routing, sigv4, xml
|
|
22
|
+
from theblob.s3.errors import S3Error
|
|
23
|
+
from theblob.s3.routing import BucketRoute, ObjectRoute, Operation, ServiceRoute
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
type Scope = Mapping[str, Any]
|
|
30
|
+
type Message = Mapping[str, Any]
|
|
31
|
+
type Receive = Callable[[], Awaitable[Message]]
|
|
32
|
+
type Send = Callable[[Message], Awaitable[None]]
|
|
33
|
+
type App = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
34
|
+
|
|
35
|
+
ENV_DATA_DIR = "THEBLOB_DATA_DIR"
|
|
36
|
+
ENV_BASE_DOMAIN = "THEBLOB_BASE_DOMAIN"
|
|
37
|
+
META_FILE = "meta.db"
|
|
38
|
+
|
|
39
|
+
_CONNECTION_CLOSE = ("connection", "close")
|
|
40
|
+
_FIRST_ERROR_STATUS = 400
|
|
41
|
+
_FIRST_SERVER_ERROR_STATUS = 500
|
|
42
|
+
_STATUS_CLIENT_GONE = 499
|
|
43
|
+
_STATUSES_WITHOUT_BODY = frozenset({204, 304})
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("theblob.access")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_app_from_env() -> App:
|
|
49
|
+
"""Build the application in a worker process, from what `serve` set up."""
|
|
50
|
+
# Here and not in `serve`: each worker is its own process, with its own logging.
|
|
51
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
52
|
+
data_dir = Path(os.environ[ENV_DATA_DIR])
|
|
53
|
+
context = Context(Database(data_dir / META_FILE), BlobStore(data_dir))
|
|
54
|
+
return create_app(context, os.environ.get(ENV_BASE_DOMAIN))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def create_app(context: Context, base_domain: str | None) -> App:
|
|
58
|
+
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
59
|
+
if scope["type"] == "lifespan":
|
|
60
|
+
await _run_lifespan(context, receive, send)
|
|
61
|
+
else:
|
|
62
|
+
await _handle_http(context, base_domain, scope, receive, send)
|
|
63
|
+
|
|
64
|
+
return app
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _run_lifespan(context: Context, receive: Receive, send: Send) -> None:
|
|
68
|
+
await receive()
|
|
69
|
+
task = asyncio.create_task(maintenance.run_forever(context.db, context.blobs))
|
|
70
|
+
await send({"type": "lifespan.startup.complete"})
|
|
71
|
+
await receive()
|
|
72
|
+
task.cancel()
|
|
73
|
+
with suppress(asyncio.CancelledError):
|
|
74
|
+
await task
|
|
75
|
+
context.db.close()
|
|
76
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _handle_http(
|
|
80
|
+
context: Context,
|
|
81
|
+
base_domain: str | None,
|
|
82
|
+
scope: Scope,
|
|
83
|
+
receive: Receive,
|
|
84
|
+
send: Send,
|
|
85
|
+
) -> None:
|
|
86
|
+
request_id = secrets.token_hex(8)
|
|
87
|
+
started = time.monotonic()
|
|
88
|
+
http_request = _parse_http(scope)
|
|
89
|
+
access = {
|
|
90
|
+
"request_id": request_id,
|
|
91
|
+
"method": http_request.method,
|
|
92
|
+
"path": http_request.raw_path.decode("latin-1"),
|
|
93
|
+
"operation": "unknown",
|
|
94
|
+
}
|
|
95
|
+
try:
|
|
96
|
+
route = routing.route(http_request, base_domain)
|
|
97
|
+
access["operation"] = route.operation.name
|
|
98
|
+
response = await _dispatch(context, http_request, route, receive)
|
|
99
|
+
except S3Error as error:
|
|
100
|
+
response = _error_response(error, http_request.raw_path, request_id)
|
|
101
|
+
except ClientDisconnectedError:
|
|
102
|
+
_log_access(access, _STATUS_CLIENT_GONE, started)
|
|
103
|
+
return
|
|
104
|
+
except Exception:
|
|
105
|
+
logger.exception("Unhandled error in request %s", request_id)
|
|
106
|
+
response = _error_response(
|
|
107
|
+
S3Error(errors.INTERNAL_ERROR), http_request.raw_path, request_id
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if "expect" in http_request.headers and response.status >= _FIRST_ERROR_STATUS:
|
|
111
|
+
# The client held its body back and will send it after no 100 Continue. If
|
|
112
|
+
# the connection stayed open, those bytes would parse as the next request.
|
|
113
|
+
response = replace(response, headers=(*response.headers, _CONNECTION_CLOSE))
|
|
114
|
+
try:
|
|
115
|
+
await _send_response(response, http_request.method, request_id, receive, send)
|
|
116
|
+
finally:
|
|
117
|
+
# Including a stream that fails midway: its status was already sent.
|
|
118
|
+
_log_access(access, response.status, started)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_http(scope: Scope) -> HttpRequest:
|
|
122
|
+
headers: dict[str, str] = {}
|
|
123
|
+
for raw_name, raw_value in scope["headers"]:
|
|
124
|
+
name = raw_name.decode("latin-1").lower()
|
|
125
|
+
value = raw_value.decode("latin-1")
|
|
126
|
+
headers[name] = f"{headers[name]},{value}" if name in headers else value
|
|
127
|
+
raw_query: bytes = scope["query_string"]
|
|
128
|
+
return HttpRequest(
|
|
129
|
+
scope["method"],
|
|
130
|
+
scope["raw_path"],
|
|
131
|
+
raw_query,
|
|
132
|
+
routing.parse_query(raw_query),
|
|
133
|
+
headers,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _dispatch(
|
|
138
|
+
context: Context,
|
|
139
|
+
http_request: HttpRequest,
|
|
140
|
+
route: routing.Route,
|
|
141
|
+
receive: Receive,
|
|
142
|
+
) -> Response:
|
|
143
|
+
if route.operation is Operation.HEALTH:
|
|
144
|
+
await meta.ping(context.db)
|
|
145
|
+
return Response(body=b"ok\n", headers=(("content-type", "text/plain"),))
|
|
146
|
+
|
|
147
|
+
claim = sigv4.parse_claim(http_request)
|
|
148
|
+
secret = await meta.get_secret(context.db, claim.credential.access_key)
|
|
149
|
+
seed = sigv4.verify(http_request, claim, secret, time.time())
|
|
150
|
+
request = Request(http_request.headers, http_request.query, receive, seed)
|
|
151
|
+
|
|
152
|
+
match route:
|
|
153
|
+
case ServiceRoute(operation=operation):
|
|
154
|
+
return await handlers.SERVICE_HANDLERS[operation](context, request)
|
|
155
|
+
case BucketRoute(operation=operation, bucket=bucket):
|
|
156
|
+
return await handlers.BUCKET_HANDLERS[operation](context, request, bucket)
|
|
157
|
+
case ObjectRoute(operation=operation, bucket=bucket, key=key):
|
|
158
|
+
return await handlers.OBJECT_HANDLERS[operation](
|
|
159
|
+
context, request, bucket, key
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _error_response(error: S3Error, raw_path: bytes, request_id: str) -> Response:
|
|
164
|
+
if error.error.status >= _FIRST_SERVER_ERROR_STATUS:
|
|
165
|
+
logger.error("%s: %s", request_id, error)
|
|
166
|
+
body = xml.render_error(
|
|
167
|
+
error.error.code,
|
|
168
|
+
error.message,
|
|
169
|
+
raw_path.decode("latin-1"),
|
|
170
|
+
request_id,
|
|
171
|
+
)
|
|
172
|
+
return Response(
|
|
173
|
+
error.error.status,
|
|
174
|
+
(("content-type", "application/xml"), *error.headers),
|
|
175
|
+
body,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def _send_response(
|
|
180
|
+
response: Response, method: str, request_id: str, receive: Receive, send: Send
|
|
181
|
+
) -> None:
|
|
182
|
+
headers = [
|
|
183
|
+
(name.encode("latin-1"), value.encode("latin-1"))
|
|
184
|
+
for name, value in response.headers
|
|
185
|
+
]
|
|
186
|
+
headers.append((b"x-amz-request-id", request_id.encode()))
|
|
187
|
+
body = response.body
|
|
188
|
+
has_length = any(name == b"content-length" for name, _ in headers)
|
|
189
|
+
if (
|
|
190
|
+
isinstance(body, bytes)
|
|
191
|
+
and not has_length
|
|
192
|
+
and response.status not in _STATUSES_WITHOUT_BODY
|
|
193
|
+
):
|
|
194
|
+
headers.append((b"content-length", str(len(body)).encode()))
|
|
195
|
+
await send({
|
|
196
|
+
"type": "http.response.start",
|
|
197
|
+
"status": response.status,
|
|
198
|
+
"headers": headers,
|
|
199
|
+
})
|
|
200
|
+
if isinstance(body, bytes):
|
|
201
|
+
payload = b"" if method == "HEAD" else body
|
|
202
|
+
await send({"type": "http.response.body", "body": payload})
|
|
203
|
+
else:
|
|
204
|
+
await _stream_body(body, receive, send)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def _stream_body(
|
|
208
|
+
body: AsyncGenerator[bytes], receive: Receive, send: Send
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Send a blob stream, and stop reading the disk once the client has gone.
|
|
211
|
+
|
|
212
|
+
uvicorn silently drops `send` after a disconnect, so nothing else would stop
|
|
213
|
+
an abandoned download from reading the whole object.
|
|
214
|
+
"""
|
|
215
|
+
disconnected = asyncio.create_task(_wait_for_disconnect(receive))
|
|
216
|
+
try:
|
|
217
|
+
async with aclosing(body) as chunks:
|
|
218
|
+
async for chunk in chunks:
|
|
219
|
+
if disconnected.done():
|
|
220
|
+
return
|
|
221
|
+
await send({
|
|
222
|
+
"type": "http.response.body",
|
|
223
|
+
"body": chunk,
|
|
224
|
+
"more_body": True,
|
|
225
|
+
})
|
|
226
|
+
await send({"type": "http.response.body", "body": b""})
|
|
227
|
+
finally:
|
|
228
|
+
disconnected.cancel()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _wait_for_disconnect(receive: Receive) -> None:
|
|
232
|
+
while (await receive())["type"] != "http.disconnect":
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _log_access(access: Mapping[str, str], status: int, started: float) -> None:
|
|
237
|
+
seconds = round(time.monotonic() - started, 6)
|
|
238
|
+
logger.info("%s", json.dumps({**access, "status": status, "seconds": seconds}))
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Blob files: durable writes, range reads and deletion.
|
|
2
|
+
|
|
3
|
+
A blob's ID is random, so user-supplied keys never reach the filesystem.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Self
|
|
14
|
+
|
|
15
|
+
from theblob.model import Blob
|
|
16
|
+
from theblob.s3 import errors
|
|
17
|
+
from theblob.s3.errors import S3Error
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import AsyncGenerator, AsyncIterable, Iterable, Mapping
|
|
21
|
+
from types import TracebackType
|
|
22
|
+
|
|
23
|
+
from theblob.model import BlobSlice
|
|
24
|
+
from theblob.s3.payload import Hasher
|
|
25
|
+
|
|
26
|
+
BUFFER_BYTES = 1024 * 1024
|
|
27
|
+
TMP_MAX_AGE_SECONDS = 24 * 3600
|
|
28
|
+
|
|
29
|
+
_SHARD_LENGTH = 3
|
|
30
|
+
_SHARD_COUNT = 16**_SHARD_LENGTH
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def existing_files(directory: Path) -> Iterable[tuple[str, os.stat_result]]:
|
|
34
|
+
"""Yield the files in a directory that other workers are changing concurrently.
|
|
35
|
+
|
|
36
|
+
A file listed a moment ago may have been committed or collected since.
|
|
37
|
+
"""
|
|
38
|
+
with os.scandir(directory) as entries:
|
|
39
|
+
for entry in entries:
|
|
40
|
+
try:
|
|
41
|
+
yield entry.path, entry.stat()
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fsync_directory(path: Path) -> None:
|
|
47
|
+
descriptor = os.open(path, os.O_RDONLY)
|
|
48
|
+
try:
|
|
49
|
+
os.fsync(descriptor)
|
|
50
|
+
finally:
|
|
51
|
+
os.close(descriptor)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class BlobStore:
|
|
56
|
+
"""A directory of immutable blob files."""
|
|
57
|
+
|
|
58
|
+
root: Path
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def tmp_dir(self) -> Path:
|
|
62
|
+
return self.root / "tmp"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def blobs_dir(self) -> Path:
|
|
66
|
+
return self.root / "blobs"
|
|
67
|
+
|
|
68
|
+
def initialize(self) -> None:
|
|
69
|
+
"""Create every shard up front, so writes never create a directory."""
|
|
70
|
+
self.tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
for number in range(_SHARD_COUNT):
|
|
72
|
+
shard = f"{number:0{_SHARD_LENGTH}x}"
|
|
73
|
+
(self.blobs_dir / shard).mkdir(parents=True, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
def path(self, blob_id: str) -> Path:
|
|
76
|
+
return self.blobs_dir / blob_id[:_SHARD_LENGTH] / blob_id
|
|
77
|
+
|
|
78
|
+
def writer(self, hashers: Mapping[str, Hasher]) -> BlobWriter:
|
|
79
|
+
return BlobWriter(self, hashers)
|
|
80
|
+
|
|
81
|
+
def delete(self, blob_id: str) -> None:
|
|
82
|
+
self.path(blob_id).unlink(missing_ok=True)
|
|
83
|
+
|
|
84
|
+
def sweep_tmp(self, now: float) -> int:
|
|
85
|
+
"""Remove temp files abandoned by a crash. Returns how many were removed."""
|
|
86
|
+
removed = 0
|
|
87
|
+
for path, stat in existing_files(self.tmp_dir):
|
|
88
|
+
if now - stat.st_mtime > TMP_MAX_AGE_SECONDS:
|
|
89
|
+
Path(path).unlink(missing_ok=True)
|
|
90
|
+
removed += 1
|
|
91
|
+
return removed
|
|
92
|
+
|
|
93
|
+
def scan(self) -> Iterable[tuple[str, int, float]]:
|
|
94
|
+
"""Yield (blob ID, size, modification time) for every blob on disk."""
|
|
95
|
+
for shard in self.blobs_dir.iterdir():
|
|
96
|
+
for path, stat in existing_files(shard):
|
|
97
|
+
yield Path(path).name, stat.st_size, stat.st_mtime
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class BlobWriter:
|
|
101
|
+
"""One blob being written. It only becomes durable and named on `commit`."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, store: BlobStore, hashers: Mapping[str, Hasher]) -> None:
|
|
104
|
+
self._store = store
|
|
105
|
+
self._hashers = hashers
|
|
106
|
+
self.blob_id = secrets.token_hex(16)
|
|
107
|
+
self._tmp_path = store.tmp_dir / self.blob_id
|
|
108
|
+
self._file = self._tmp_path.open("xb")
|
|
109
|
+
self._committed = False
|
|
110
|
+
self.size = 0
|
|
111
|
+
|
|
112
|
+
def __enter__(self) -> Self:
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def __exit__(
|
|
116
|
+
self,
|
|
117
|
+
exc_type: type[BaseException] | None,
|
|
118
|
+
exc: BaseException | None,
|
|
119
|
+
traceback: TracebackType | None,
|
|
120
|
+
) -> None:
|
|
121
|
+
self._file.close()
|
|
122
|
+
if not self._committed:
|
|
123
|
+
self._tmp_path.unlink(missing_ok=True)
|
|
124
|
+
|
|
125
|
+
def write(self, data: bytes) -> None:
|
|
126
|
+
"""Hash and write one buffer. Runs on a worker thread."""
|
|
127
|
+
for hasher in self._hashers.values():
|
|
128
|
+
hasher.update(data)
|
|
129
|
+
self._file.write(data)
|
|
130
|
+
self.size += len(data)
|
|
131
|
+
|
|
132
|
+
def digests(self) -> dict[str, bytes]:
|
|
133
|
+
return {name: hasher.digest() for name, hasher in self._hashers.items()}
|
|
134
|
+
|
|
135
|
+
def commit(self) -> Blob:
|
|
136
|
+
"""Make the blob durable under its final name. Runs on a worker thread."""
|
|
137
|
+
self._file.flush()
|
|
138
|
+
# ponytail: on macOS fsync doesn't flush the drive cache. Linux is the
|
|
139
|
+
# target; add fcntl F_FULLFSYNC if it is ever deployed on macOS.
|
|
140
|
+
os.fsync(self._file.fileno())
|
|
141
|
+
self._file.close()
|
|
142
|
+
|
|
143
|
+
final_path = self._store.path(self.blob_id)
|
|
144
|
+
self._tmp_path.rename(final_path)
|
|
145
|
+
_fsync_directory(final_path.parent)
|
|
146
|
+
self._committed = True
|
|
147
|
+
return Blob(self.blob_id, self.size, self._hashers["md5"].digest().hex())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def write_stream(
|
|
151
|
+
writer: BlobWriter, chunks: AsyncIterable[bytes], max_bytes: int
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Feed a stream to a writer, one thread-pool trip per buffer of data."""
|
|
154
|
+
buffer = bytearray()
|
|
155
|
+
received = 0
|
|
156
|
+
async for chunk in chunks:
|
|
157
|
+
received += len(chunk)
|
|
158
|
+
if received > max_bytes:
|
|
159
|
+
raise S3Error(errors.ENTITY_TOO_LARGE)
|
|
160
|
+
buffer += chunk
|
|
161
|
+
if len(buffer) >= BUFFER_BYTES:
|
|
162
|
+
await asyncio.to_thread(writer.write, bytes(buffer))
|
|
163
|
+
buffer.clear()
|
|
164
|
+
if buffer:
|
|
165
|
+
await asyncio.to_thread(writer.write, bytes(buffer))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def read_slices(
|
|
169
|
+
store: BlobStore, slices: Iterable[BlobSlice]
|
|
170
|
+
) -> AsyncGenerator[bytes]:
|
|
171
|
+
"""Stream byte ranges of blobs, opening each blob only when it is reached."""
|
|
172
|
+
for blob_slice in slices:
|
|
173
|
+
descriptor = await asyncio.to_thread(
|
|
174
|
+
os.open, store.path(blob_slice.blob_id), os.O_RDONLY
|
|
175
|
+
)
|
|
176
|
+
try:
|
|
177
|
+
position = blob_slice.offset
|
|
178
|
+
remaining = blob_slice.length
|
|
179
|
+
while remaining > 0:
|
|
180
|
+
data = await asyncio.to_thread(
|
|
181
|
+
os.pread, descriptor, min(BUFFER_BYTES, remaining), position
|
|
182
|
+
)
|
|
183
|
+
if not data:
|
|
184
|
+
message = f"Blob {blob_slice.blob_id} is shorter than its metadata."
|
|
185
|
+
raise OSError(message)
|
|
186
|
+
position += len(data)
|
|
187
|
+
remaining -= len(data)
|
|
188
|
+
yield data
|
|
189
|
+
finally:
|
|
190
|
+
os.close(descriptor)
|