zerobucket 0.3.0__tar.gz → 0.4.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.
- {zerobucket-0.3.0 → zerobucket-0.4.0}/PKG-INFO +13 -1
- {zerobucket-0.3.0 → zerobucket-0.4.0}/README.md +12 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/pyproject.toml +1 -1
- {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/__init__.py +1 -1
- {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/adapters/base.py +20 -4
- {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/adapters/postgres.py +44 -9
- {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/client.py +44 -12
- zerobucket-0.4.0/tests/test_transactions.py +151 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/.gitignore +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/0 +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/float +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/floor +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/np.ndarray +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/adapters/__init__.py +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/exceptions.py +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/optimization.py +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/py.typed +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/types.py +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/validation.py +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/__init__.py +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/conftest.py +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/photo_fixtures.py +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/test_client.py +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/test_errors.py +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/test_optimization.py +0 -0
- {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/test_validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: zerobucket
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Database-native image storage. Your database. Your images. Zero buckets.
|
|
5
5
|
Project-URL: Homepage, https://github.com/KedarGhadyalji/ZeroBucket
|
|
6
6
|
Project-URL: Repository, https://github.com/KedarGhadyalji/ZeroBucket
|
|
@@ -161,6 +161,18 @@ on GitHub for the full methodology.
|
|
|
161
161
|
[benchmark results](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/benchmarks/RESULTS.md)
|
|
162
162
|
for why.
|
|
163
163
|
|
|
164
|
+
## Transactions
|
|
165
|
+
|
|
166
|
+
By default, `put()`/`get()`/`delete()` each use their own independent
|
|
167
|
+
database connection -- **not** your application's own transaction, even
|
|
168
|
+
against the same database. Pass your own open `psycopg` connection via
|
|
169
|
+
`connection=` to make a write participate in your transaction (e.g. "user
|
|
170
|
+
|
|
171
|
+
- avatar, atomically, or neither"). See the
|
|
172
|
+
[Transactions section](https://github.com/KedarGhadyalji/ZeroBucket#transactions)
|
|
173
|
+
on GitHub for a worked example -- this was verified by direct experiment
|
|
174
|
+
during development, not assumed.
|
|
175
|
+
|
|
164
176
|
## Limitations (read before using in production)
|
|
165
177
|
|
|
166
178
|
- **Not built for large files or high-volume media.** Full images are
|
|
@@ -123,6 +123,18 @@ on GitHub for the full methodology.
|
|
|
123
123
|
[benchmark results](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/benchmarks/RESULTS.md)
|
|
124
124
|
for why.
|
|
125
125
|
|
|
126
|
+
## Transactions
|
|
127
|
+
|
|
128
|
+
By default, `put()`/`get()`/`delete()` each use their own independent
|
|
129
|
+
database connection -- **not** your application's own transaction, even
|
|
130
|
+
against the same database. Pass your own open `psycopg` connection via
|
|
131
|
+
`connection=` to make a write participate in your transaction (e.g. "user
|
|
132
|
+
|
|
133
|
+
- avatar, atomically, or neither"). See the
|
|
134
|
+
[Transactions section](https://github.com/KedarGhadyalji/ZeroBucket#transactions)
|
|
135
|
+
on GitHub for a worked example -- this was verified by direct experiment
|
|
136
|
+
during development, not assumed.
|
|
137
|
+
|
|
126
138
|
## Limitations (read before using in production)
|
|
127
139
|
|
|
128
140
|
- **Not built for large files or high-volume media.** Full images are
|
|
@@ -7,6 +7,17 @@ zerobucket.client, above this layer.
|
|
|
7
7
|
|
|
8
8
|
This separation is what makes a future object-storage backend a real
|
|
9
9
|
drop-in replacement rather than a rewrite.
|
|
10
|
+
|
|
11
|
+
Every method accepts an optional `connection`. When None (the default),
|
|
12
|
+
the backend uses its own internal pool -- each call commits independently
|
|
13
|
+
on its own connection, exactly as before. When a connection is provided,
|
|
14
|
+
the backend uses it directly and does NOT commit or roll it back -- that
|
|
15
|
+
becomes the caller's responsibility, which is what lets an operation
|
|
16
|
+
participate in the caller's own transaction (see client.py's put() docs
|
|
17
|
+
for why this matters and a worked example). `connection` is intentionally
|
|
18
|
+
typed as `object` here rather than a Postgres-specific type, since this
|
|
19
|
+
interface is meant to stay backend-agnostic; concrete adapters narrow the
|
|
20
|
+
type in their own implementation.
|
|
10
21
|
"""
|
|
11
22
|
|
|
12
23
|
from __future__ import annotations
|
|
@@ -56,23 +67,28 @@ class StorageBackend(ABC):
|
|
|
56
67
|
width: int | None,
|
|
57
68
|
height: int | None,
|
|
58
69
|
checksum_sha256: str,
|
|
70
|
+
connection: object | None = None,
|
|
59
71
|
) -> str:
|
|
60
72
|
"""Persist a record and return its generated id."""
|
|
61
73
|
|
|
62
74
|
@abstractmethod
|
|
63
|
-
def get(
|
|
75
|
+
def get(
|
|
76
|
+
self, image_id: str, *, connection: object | None = None
|
|
77
|
+
) -> StoredRecord | None:
|
|
64
78
|
"""Fetch a full record including bytes, or None if it doesn't exist."""
|
|
65
79
|
|
|
66
80
|
@abstractmethod
|
|
67
|
-
def get_metadata(
|
|
81
|
+
def get_metadata(
|
|
82
|
+
self, image_id: str, *, connection: object | None = None
|
|
83
|
+
) -> StoredRecordMetadata | None:
|
|
68
84
|
"""Fetch metadata only (no bytes), or None if it doesn't exist."""
|
|
69
85
|
|
|
70
86
|
@abstractmethod
|
|
71
|
-
def delete(self, image_id: str) -> bool:
|
|
87
|
+
def delete(self, image_id: str, *, connection: object | None = None) -> bool:
|
|
72
88
|
"""Delete a record. Returns True if a record was deleted, False if it didn't exist."""
|
|
73
89
|
|
|
74
90
|
@abstractmethod
|
|
75
|
-
def exists(self, image_id: str) -> bool:
|
|
91
|
+
def exists(self, image_id: str, *, connection: object | None = None) -> bool:
|
|
76
92
|
"""Return whether a record with this id exists."""
|
|
77
93
|
|
|
78
94
|
@abstractmethod
|
|
@@ -6,11 +6,17 @@ parameterized; nothing is ever built via string concatenation.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
9
12
|
from psycopg_pool import ConnectionPool
|
|
10
13
|
|
|
11
14
|
from ..exceptions import StorageError
|
|
12
15
|
from .base import StorageBackend, StoredRecord, StoredRecordMetadata
|
|
13
16
|
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import psycopg
|
|
19
|
+
|
|
14
20
|
_SCHEMA = """
|
|
15
21
|
CREATE TABLE IF NOT EXISTS zerobucket_images (
|
|
16
22
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -85,6 +91,26 @@ class PostgresBackend(StorageBackend):
|
|
|
85
91
|
except Exception as exc: # noqa: BLE001
|
|
86
92
|
raise StorageError(f"Migration failed: {exc}") from exc
|
|
87
93
|
|
|
94
|
+
@contextmanager
|
|
95
|
+
def _cursor(self, connection: psycopg.Connection | None):
|
|
96
|
+
"""Yield a cursor, either on the caller's own connection or a
|
|
97
|
+
pooled one.
|
|
98
|
+
|
|
99
|
+
When `connection` is provided, we use it directly and do NOT
|
|
100
|
+
commit or roll it back -- that's the caller's responsibility,
|
|
101
|
+
and is precisely what lets a put()/delete() participate in the
|
|
102
|
+
caller's own transaction (see client.py). When `connection` is
|
|
103
|
+
None, we fall back to the internal pool exactly as before: each
|
|
104
|
+
call gets its own connection and commits independently on clean
|
|
105
|
+
exit.
|
|
106
|
+
"""
|
|
107
|
+
if connection is not None:
|
|
108
|
+
with connection.cursor() as cur:
|
|
109
|
+
yield cur
|
|
110
|
+
else:
|
|
111
|
+
with self._pool.connection() as conn, conn.cursor() as cur:
|
|
112
|
+
yield cur
|
|
113
|
+
|
|
88
114
|
def put(
|
|
89
115
|
self,
|
|
90
116
|
*,
|
|
@@ -95,9 +121,10 @@ class PostgresBackend(StorageBackend):
|
|
|
95
121
|
width: int | None,
|
|
96
122
|
height: int | None,
|
|
97
123
|
checksum_sha256: str,
|
|
124
|
+
connection: psycopg.Connection | None = None,
|
|
98
125
|
) -> str:
|
|
99
126
|
try:
|
|
100
|
-
with self.
|
|
127
|
+
with self._cursor(connection) as cur:
|
|
101
128
|
params = (
|
|
102
129
|
data,
|
|
103
130
|
mime_type,
|
|
@@ -113,9 +140,11 @@ class PostgresBackend(StorageBackend):
|
|
|
113
140
|
except Exception as exc: # noqa: BLE001
|
|
114
141
|
raise StorageError(f"Failed to store image: {exc}") from exc
|
|
115
142
|
|
|
116
|
-
def get(
|
|
143
|
+
def get(
|
|
144
|
+
self, image_id: str, *, connection: psycopg.Connection | None = None
|
|
145
|
+
) -> StoredRecord | None:
|
|
117
146
|
try:
|
|
118
|
-
with self.
|
|
147
|
+
with self._cursor(connection) as cur:
|
|
119
148
|
cur.execute(_SELECT_FULL, (image_id,))
|
|
120
149
|
row = cur.fetchone()
|
|
121
150
|
except Exception as exc: # noqa: BLE001
|
|
@@ -133,9 +162,11 @@ class PostgresBackend(StorageBackend):
|
|
|
133
162
|
checksum_sha256=row[7],
|
|
134
163
|
)
|
|
135
164
|
|
|
136
|
-
def get_metadata(
|
|
165
|
+
def get_metadata(
|
|
166
|
+
self, image_id: str, *, connection: psycopg.Connection | None = None
|
|
167
|
+
) -> StoredRecordMetadata | None:
|
|
137
168
|
try:
|
|
138
|
-
with self.
|
|
169
|
+
with self._cursor(connection) as cur:
|
|
139
170
|
cur.execute(_SELECT_METADATA, (image_id,))
|
|
140
171
|
row = cur.fetchone()
|
|
141
172
|
except Exception as exc: # noqa: BLE001
|
|
@@ -152,17 +183,21 @@ class PostgresBackend(StorageBackend):
|
|
|
152
183
|
checksum_sha256=row[6],
|
|
153
184
|
)
|
|
154
185
|
|
|
155
|
-
def delete(
|
|
186
|
+
def delete(
|
|
187
|
+
self, image_id: str, *, connection: psycopg.Connection | None = None
|
|
188
|
+
) -> bool:
|
|
156
189
|
try:
|
|
157
|
-
with self.
|
|
190
|
+
with self._cursor(connection) as cur:
|
|
158
191
|
cur.execute(_DELETE, (image_id,))
|
|
159
192
|
return cur.rowcount > 0
|
|
160
193
|
except Exception as exc: # noqa: BLE001
|
|
161
194
|
raise StorageError(f"Failed to delete image: {exc}") from exc
|
|
162
195
|
|
|
163
|
-
def exists(
|
|
196
|
+
def exists(
|
|
197
|
+
self, image_id: str, *, connection: psycopg.Connection | None = None
|
|
198
|
+
) -> bool:
|
|
164
199
|
try:
|
|
165
|
-
with self.
|
|
200
|
+
with self._cursor(connection) as cur:
|
|
166
201
|
cur.execute(_EXISTS, (image_id,))
|
|
167
202
|
return cur.fetchone() is not None
|
|
168
203
|
except Exception as exc: # noqa: BLE001
|
|
@@ -77,6 +77,7 @@ class ZeroBucket:
|
|
|
77
77
|
max_width: int | None = None,
|
|
78
78
|
format: str | None = None,
|
|
79
79
|
quality: int | None = None,
|
|
80
|
+
connection: object | None = None,
|
|
80
81
|
) -> str:
|
|
81
82
|
"""Validate, optionally optimize, and store an image. Returns its id.
|
|
82
83
|
|
|
@@ -104,6 +105,17 @@ class ZeroBucket:
|
|
|
104
105
|
zerobucket.optimization for the reasoning and
|
|
105
106
|
tests/test_optimization.py for the SSIM regression test
|
|
106
107
|
that enforces it). Only applies when optimize=True.
|
|
108
|
+
connection: Advanced -- pass your own open psycopg connection
|
|
109
|
+
(one you're already using for other writes in the same
|
|
110
|
+
transaction) to make this put() commit or roll back
|
|
111
|
+
together with the rest of that transaction, instead of
|
|
112
|
+
committing independently on ZeroBucket's own internal
|
|
113
|
+
pool. Without this, put() ALWAYS commits on its own,
|
|
114
|
+
regardless of what your application does afterward --
|
|
115
|
+
see the README's "Transactions" section for a worked
|
|
116
|
+
example and why this matters (e.g. "create a user record
|
|
117
|
+
and store their avatar atomically" only works if you
|
|
118
|
+
pass connection= here).
|
|
107
119
|
"""
|
|
108
120
|
data, resolved_filename = _read_image_input(image, filename)
|
|
109
121
|
|
|
@@ -148,11 +160,17 @@ class ZeroBucket:
|
|
|
148
160
|
width=width,
|
|
149
161
|
height=height,
|
|
150
162
|
checksum_sha256=checksum,
|
|
163
|
+
connection=connection,
|
|
151
164
|
)
|
|
152
165
|
|
|
153
|
-
def get(self, image_id: str) -> Image:
|
|
154
|
-
"""Retrieve a full image, including bytes. Raises ImageNotFoundError if missing.
|
|
155
|
-
|
|
166
|
+
def get(self, image_id: str, *, connection: object | None = None) -> Image:
|
|
167
|
+
"""Retrieve a full image, including bytes. Raises ImageNotFoundError if missing.
|
|
168
|
+
|
|
169
|
+
connection: Advanced -- see put()'s docstring. Passing the same
|
|
170
|
+
open transaction lets you read back a row you just wrote in
|
|
171
|
+
that same transaction, before it's committed.
|
|
172
|
+
"""
|
|
173
|
+
record = self._backend.get(image_id, connection=connection)
|
|
156
174
|
if record is None:
|
|
157
175
|
raise ImageNotFoundError(image_id)
|
|
158
176
|
return Image(
|
|
@@ -165,9 +183,14 @@ class ZeroBucket:
|
|
|
165
183
|
checksum_sha256=record.checksum_sha256,
|
|
166
184
|
)
|
|
167
185
|
|
|
168
|
-
def metadata(
|
|
169
|
-
|
|
170
|
-
|
|
186
|
+
def metadata(
|
|
187
|
+
self, image_id: str, *, connection: object | None = None
|
|
188
|
+
) -> ImageMetadata:
|
|
189
|
+
"""Retrieve image metadata without pulling the (potentially large) bytes.
|
|
190
|
+
|
|
191
|
+
connection: Advanced -- see put()'s docstring.
|
|
192
|
+
"""
|
|
193
|
+
record = self._backend.get_metadata(image_id, connection=connection)
|
|
171
194
|
if record is None:
|
|
172
195
|
raise ImageNotFoundError(image_id)
|
|
173
196
|
return ImageMetadata(
|
|
@@ -180,13 +203,22 @@ class ZeroBucket:
|
|
|
180
203
|
checksum_sha256=record.checksum_sha256,
|
|
181
204
|
)
|
|
182
205
|
|
|
183
|
-
def exists(self, image_id: str) -> bool:
|
|
184
|
-
"""Return whether an image with this id exists.
|
|
185
|
-
return self._backend.exists(image_id)
|
|
206
|
+
def exists(self, image_id: str, *, connection: object | None = None) -> bool:
|
|
207
|
+
"""Return whether an image with this id exists.
|
|
186
208
|
|
|
187
|
-
|
|
188
|
-
"""
|
|
189
|
-
return self._backend.
|
|
209
|
+
connection: Advanced -- see put()'s docstring.
|
|
210
|
+
"""
|
|
211
|
+
return self._backend.exists(image_id, connection=connection)
|
|
212
|
+
|
|
213
|
+
def delete(self, image_id: str, *, connection: object | None = None) -> bool:
|
|
214
|
+
"""Delete an image. Returns True if it existed and was deleted, False otherwise.
|
|
215
|
+
|
|
216
|
+
connection: Advanced -- see put()'s docstring. Passing the same
|
|
217
|
+
open transaction lets a delete() roll back together with the
|
|
218
|
+
rest of that transaction (e.g. "delete a user and their
|
|
219
|
+
avatar atomically").
|
|
220
|
+
"""
|
|
221
|
+
return self._backend.delete(image_id, connection=connection)
|
|
190
222
|
|
|
191
223
|
def close(self) -> None:
|
|
192
224
|
"""Release underlying database connections."""
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Tests for the connection= parameter: does put()/delete() actually
|
|
2
|
+
participate in the caller's own transaction when given one, and does the
|
|
3
|
+
default behavior (no connection given) remain independently-committing,
|
|
4
|
+
exactly as it always has?
|
|
5
|
+
|
|
6
|
+
These mirror a real experiment run during development: without
|
|
7
|
+
connection=, a put() survives even when the caller's own separate
|
|
8
|
+
transaction rolls back -- proving the two are NOT atomic by default. That
|
|
9
|
+
finding is what motivated this feature; these tests lock in both the old
|
|
10
|
+
default behavior and the new opt-in behavior so neither regresses.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import io
|
|
16
|
+
|
|
17
|
+
import psycopg
|
|
18
|
+
from PIL import Image as PILImage
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _jpeg_bytes() -> bytes:
|
|
22
|
+
img = PILImage.new("RGB", (40, 30), color=(10, 20, 30))
|
|
23
|
+
buf = io.BytesIO()
|
|
24
|
+
img.save(buf, format="JPEG")
|
|
25
|
+
return buf.getvalue()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_put_without_connection_commits_independently_of_caller_transaction(images):
|
|
29
|
+
"""Default (unchanged) behavior: put() ALWAYS commits on its own,
|
|
30
|
+
regardless of what the caller's own separate connection does
|
|
31
|
+
afterward. This is the current, real behavior -- not an aspiration --
|
|
32
|
+
and this test exists specifically so it can't silently change without
|
|
33
|
+
someone noticing."""
|
|
34
|
+
from tests.conftest import TEST_DATABASE_URL
|
|
35
|
+
|
|
36
|
+
app_conn = psycopg.connect(TEST_DATABASE_URL)
|
|
37
|
+
app_conn.autocommit = False
|
|
38
|
+
try:
|
|
39
|
+
with app_conn.cursor() as cur:
|
|
40
|
+
cur.execute(
|
|
41
|
+
"CREATE TABLE IF NOT EXISTS _test_app_scratch (id serial primary key);"
|
|
42
|
+
)
|
|
43
|
+
app_conn.commit()
|
|
44
|
+
|
|
45
|
+
with app_conn.cursor() as cur:
|
|
46
|
+
cur.execute("INSERT INTO _test_app_scratch DEFAULT VALUES;")
|
|
47
|
+
|
|
48
|
+
image_id = images.put(_jpeg_bytes()) # no connection= passed
|
|
49
|
+
|
|
50
|
+
app_conn.rollback()
|
|
51
|
+
|
|
52
|
+
with app_conn.cursor() as cur:
|
|
53
|
+
cur.execute("SELECT count(*) FROM _test_app_scratch;")
|
|
54
|
+
assert cur.fetchone()[0] == 0 # app's own insert was rolled back
|
|
55
|
+
|
|
56
|
+
# But the image was NOT rolled back -- it committed independently.
|
|
57
|
+
assert images.exists(image_id) is True
|
|
58
|
+
finally:
|
|
59
|
+
with app_conn.cursor() as cur:
|
|
60
|
+
cur.execute("DROP TABLE IF EXISTS _test_app_scratch;")
|
|
61
|
+
app_conn.commit()
|
|
62
|
+
app_conn.close()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_put_with_connection_rolls_back_with_caller_transaction(images):
|
|
66
|
+
"""The new opt-in behavior: passing connection= makes put() a real
|
|
67
|
+
part of the caller's transaction -- if the caller rolls back, the
|
|
68
|
+
image row is gone too, same as any other write in that transaction."""
|
|
69
|
+
from tests.conftest import TEST_DATABASE_URL
|
|
70
|
+
|
|
71
|
+
app_conn = psycopg.connect(TEST_DATABASE_URL)
|
|
72
|
+
app_conn.autocommit = False
|
|
73
|
+
try:
|
|
74
|
+
image_id = images.put(_jpeg_bytes(), connection=app_conn)
|
|
75
|
+
|
|
76
|
+
# Readable within the SAME still-open transaction (read-your-writes).
|
|
77
|
+
assert images.exists(image_id, connection=app_conn) is True
|
|
78
|
+
|
|
79
|
+
app_conn.rollback()
|
|
80
|
+
|
|
81
|
+
# After rollback, the image must be gone -- it was never committed.
|
|
82
|
+
assert images.exists(image_id) is False
|
|
83
|
+
finally:
|
|
84
|
+
app_conn.close()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_put_with_connection_commits_with_caller_transaction(images):
|
|
88
|
+
"""The success path: passing connection= and then committing the
|
|
89
|
+
caller's own transaction commits the image too."""
|
|
90
|
+
from tests.conftest import TEST_DATABASE_URL
|
|
91
|
+
|
|
92
|
+
app_conn = psycopg.connect(TEST_DATABASE_URL)
|
|
93
|
+
app_conn.autocommit = False
|
|
94
|
+
try:
|
|
95
|
+
image_id = images.put(_jpeg_bytes(), connection=app_conn)
|
|
96
|
+
app_conn.commit()
|
|
97
|
+
|
|
98
|
+
assert images.exists(image_id) is True
|
|
99
|
+
images.delete(image_id)
|
|
100
|
+
finally:
|
|
101
|
+
app_conn.close()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_delete_with_connection_rolls_back_with_caller_transaction(images):
|
|
105
|
+
"""Symmetric case for delete(): a delete() done inside a caller's
|
|
106
|
+
transaction is undone if that transaction rolls back."""
|
|
107
|
+
from tests.conftest import TEST_DATABASE_URL
|
|
108
|
+
|
|
109
|
+
image_id = images.put(_jpeg_bytes())
|
|
110
|
+
assert images.exists(image_id) is True
|
|
111
|
+
|
|
112
|
+
app_conn = psycopg.connect(TEST_DATABASE_URL)
|
|
113
|
+
app_conn.autocommit = False
|
|
114
|
+
try:
|
|
115
|
+
images.delete(image_id, connection=app_conn)
|
|
116
|
+
# Within the same open transaction, it looks deleted.
|
|
117
|
+
assert images.exists(image_id, connection=app_conn) is False
|
|
118
|
+
|
|
119
|
+
app_conn.rollback()
|
|
120
|
+
|
|
121
|
+
# After rollback, the image is back -- the delete never committed.
|
|
122
|
+
assert images.exists(image_id) is True
|
|
123
|
+
finally:
|
|
124
|
+
app_conn.close()
|
|
125
|
+
images.delete(image_id)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_get_with_connection_sees_uncommitted_write_in_same_transaction(images):
|
|
129
|
+
"""Read-your-writes: get() with the same open connection can see a
|
|
130
|
+
row that was put() in that same transaction but not yet committed --
|
|
131
|
+
proving they really do share one transaction, not just coincidence."""
|
|
132
|
+
from tests.conftest import TEST_DATABASE_URL
|
|
133
|
+
|
|
134
|
+
app_conn = psycopg.connect(TEST_DATABASE_URL)
|
|
135
|
+
app_conn.autocommit = False
|
|
136
|
+
try:
|
|
137
|
+
data = _jpeg_bytes()
|
|
138
|
+
image_id = images.put(data, connection=app_conn)
|
|
139
|
+
|
|
140
|
+
# A separate, ordinary ZeroBucket call (its own connection, no
|
|
141
|
+
# connection= passed) should NOT see this uncommitted row yet.
|
|
142
|
+
assert images.exists(image_id) is False
|
|
143
|
+
|
|
144
|
+
# But reading through the SAME transaction sees it fine.
|
|
145
|
+
result = images.get(image_id, connection=app_conn)
|
|
146
|
+
assert result.data == data
|
|
147
|
+
|
|
148
|
+
app_conn.rollback()
|
|
149
|
+
assert images.exists(image_id) is False
|
|
150
|
+
finally:
|
|
151
|
+
app_conn.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|