hishel 1.1.7__tar.gz → 1.1.8__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.
- {hishel-1.1.7 → hishel-1.1.8}/CHANGELOG.md +14 -1
- {hishel-1.1.7 → hishel-1.1.8}/PKG-INFO +15 -2
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_storages/_async_sqlite.py +155 -138
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_storages/_sync_sqlite.py +155 -138
- {hishel-1.1.7 → hishel-1.1.8}/pyproject.toml +1 -1
- {hishel-1.1.7 → hishel-1.1.8}/.gitignore +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/LICENSE +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/README.md +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/__init__.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_async_cache.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_async_httpx.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_headers.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_spec.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_storages/_async_base.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_storages/_packing.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_storages/_sync_base.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/models.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_policies.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_sync_cache.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_sync_httpx.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/_utils.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/asgi.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/fastapi.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/httpx.py +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/py.typed +0 -0
- {hishel-1.1.7 → hishel-1.1.8}/hishel/requests.py +0 -0
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## What's Changed in 1.1.8
|
|
2
|
+
### ⚙️ Miscellaneous Tasks
|
|
3
|
+
|
|
4
|
+
* chore(ci): remove redis action by @karpetrosyan in [#428](https://github.com/karpetrosyan/hishel/pull/428)
|
|
5
|
+
### 🐛 Bug Fixes
|
|
6
|
+
|
|
7
|
+
* fix: prevent race conditions by @karpetrosyan in [#436](https://github.com/karpetrosyan/hishel/pull/436)
|
|
8
|
+
|
|
9
|
+
### Contributors
|
|
10
|
+
* @karpetrosyan
|
|
11
|
+
|
|
12
|
+
**Full Changelog**: https://github.com/karpetrosyan/hishel/compare/1.1.7...1.1.8
|
|
13
|
+
|
|
1
14
|
## What's Changed in 1.1.7
|
|
2
15
|
### ♻️ Refactoring
|
|
3
16
|
|
|
@@ -16,11 +29,11 @@
|
|
|
16
29
|
* Feature/accept pathlib path in SqliteStorage by @daudef in [#419](https://github.com/karpetrosyan/hishel/pull/419)
|
|
17
30
|
|
|
18
31
|
### Contributors
|
|
32
|
+
* @karpetrosyan
|
|
19
33
|
* @daudef
|
|
20
34
|
* @dependabot[bot]
|
|
21
35
|
* @jeefberkey
|
|
22
36
|
* @dump247
|
|
23
|
-
* @karpetrosyan
|
|
24
37
|
|
|
25
38
|
**Full Changelog**: https://github.com/karpetrosyan/hishel/compare/1.1.6...1.1.7
|
|
26
39
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hishel
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.8
|
|
4
4
|
Summary: Elegant HTTP Caching for Python
|
|
5
5
|
Project-URL: Homepage, https://hishel.com
|
|
6
6
|
Project-URL: Source, https://github.com/karpetrosyan/hishel
|
|
@@ -406,6 +406,19 @@ Hishel is inspired by and builds upon the excellent work in the Python HTTP ecos
|
|
|
406
406
|
<strong>Made with ❤️ by <a href="https://github.com/karpetrosyan">Kar Petrosyan</a></strong>
|
|
407
407
|
</p>
|
|
408
408
|
|
|
409
|
+
## What's Changed in 1.1.8
|
|
410
|
+
### ⚙️ Miscellaneous Tasks
|
|
411
|
+
|
|
412
|
+
* chore(ci): remove redis action by @karpetrosyan in [#428](https://github.com/karpetrosyan/hishel/pull/428)
|
|
413
|
+
### 🐛 Bug Fixes
|
|
414
|
+
|
|
415
|
+
* fix: prevent race conditions by @karpetrosyan in [#436](https://github.com/karpetrosyan/hishel/pull/436)
|
|
416
|
+
|
|
417
|
+
### Contributors
|
|
418
|
+
* @karpetrosyan
|
|
419
|
+
|
|
420
|
+
**Full Changelog**: https://github.com/karpetrosyan/hishel/compare/1.1.7...1.1.8
|
|
421
|
+
|
|
409
422
|
## What's Changed in 1.1.7
|
|
410
423
|
### ♻️ Refactoring
|
|
411
424
|
|
|
@@ -424,11 +437,11 @@ Hishel is inspired by and builds upon the excellent work in the Python HTTP ecos
|
|
|
424
437
|
* Feature/accept pathlib path in SqliteStorage by @daudef in [#419](https://github.com/karpetrosyan/hishel/pull/419)
|
|
425
438
|
|
|
426
439
|
### Contributors
|
|
440
|
+
* @karpetrosyan
|
|
427
441
|
* @daudef
|
|
428
442
|
* @dependabot[bot]
|
|
429
443
|
* @jeefberkey
|
|
430
444
|
* @dump247
|
|
431
|
-
* @karpetrosyan
|
|
432
445
|
|
|
433
446
|
**Full Changelog**: https://github.com/karpetrosyan/hishel/compare/1.1.6...1.1.7
|
|
434
447
|
|
|
@@ -35,6 +35,7 @@ BATCH_CLEANUP_CHUNK_SIZE = 200
|
|
|
35
35
|
|
|
36
36
|
try:
|
|
37
37
|
import anysqlite
|
|
38
|
+
from anyio import Lock
|
|
38
39
|
|
|
39
40
|
class AsyncSqliteStorage(AsyncBaseStorage):
|
|
40
41
|
_COMPLETE_CHUNK_NUMBER = -1
|
|
@@ -55,9 +56,15 @@ try:
|
|
|
55
56
|
# When this storage instance was created. Used to delay the first cleanup.
|
|
56
57
|
self._start_time = time.time()
|
|
57
58
|
self._initialized = False
|
|
59
|
+
self._lock = Lock()
|
|
58
60
|
|
|
59
61
|
async def _ensure_connection(self) -> anysqlite.Connection:
|
|
60
|
-
"""
|
|
62
|
+
"""
|
|
63
|
+
Ensure connection is established and database is initialized.
|
|
64
|
+
|
|
65
|
+
Note: This method assumes the caller has already acquired the lock.
|
|
66
|
+
"""
|
|
67
|
+
|
|
61
68
|
if self.connection is None:
|
|
62
69
|
# Create cache directory and resolve full path on first connection
|
|
63
70
|
parent = self.database_path.parent if self.database_path.parent != Path(".") else None
|
|
@@ -106,151 +113,156 @@ try:
|
|
|
106
113
|
) -> Entry:
|
|
107
114
|
key_bytes = key.encode("utf-8")
|
|
108
115
|
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
async with self._lock:
|
|
117
|
+
connection = await self._ensure_connection()
|
|
118
|
+
cursor = await connection.cursor()
|
|
111
119
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
# Create a new entry directly with both request and response
|
|
121
|
+
pair_id = id_ if id_ is not None else uuid.uuid4()
|
|
122
|
+
pair_meta = EntryMeta(
|
|
123
|
+
created_at=time.time(),
|
|
124
|
+
)
|
|
117
125
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
assert isinstance(response.stream, (AsyncIterator, AsyncIterable))
|
|
127
|
+
response_with_stream = replace(
|
|
128
|
+
response,
|
|
129
|
+
stream=self._save_stream_unlocked(response.stream, pair_id.bytes),
|
|
130
|
+
)
|
|
123
131
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
132
|
+
complete_entry = Entry(
|
|
133
|
+
id=pair_id,
|
|
134
|
+
request=request,
|
|
135
|
+
response=response_with_stream,
|
|
136
|
+
meta=pair_meta,
|
|
137
|
+
cache_key=key_bytes,
|
|
138
|
+
)
|
|
131
139
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
# Insert the complete entry into the database
|
|
141
|
+
await cursor.execute(
|
|
142
|
+
"INSERT INTO entries (id, cache_key, data, created_at, deleted_at) VALUES (?, ?, ?, ?, ?)",
|
|
143
|
+
(pair_id.bytes, key_bytes, pack(complete_entry, kind="pair"), pair_meta.created_at, None),
|
|
144
|
+
)
|
|
145
|
+
await connection.commit()
|
|
138
146
|
|
|
139
|
-
|
|
147
|
+
return complete_entry
|
|
140
148
|
|
|
141
149
|
async def get_entries(self, key: str) -> List[Entry]:
|
|
142
150
|
final_pairs: List[Entry] = []
|
|
143
151
|
|
|
144
152
|
now = time.time()
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
async with self._lock:
|
|
154
|
+
if now - self.last_cleanup >= BATCH_CLEANUP_INTERVAL:
|
|
155
|
+
try:
|
|
156
|
+
await self._batch_cleanup()
|
|
157
|
+
except Exception:
|
|
158
|
+
# don't let cleanup prevent reads; failures are non-fatal
|
|
159
|
+
pass
|
|
151
160
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
connection = await self._ensure_connection()
|
|
162
|
+
cursor = await connection.cursor()
|
|
163
|
+
# Query entries directly by cache_key
|
|
164
|
+
await cursor.execute(
|
|
165
|
+
"SELECT id, data FROM entries WHERE cache_key = ?",
|
|
166
|
+
(key.encode("utf-8"),),
|
|
167
|
+
)
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
for row in await cursor.fetchall():
|
|
170
|
+
pair_data = unpack(row[1], kind="pair")
|
|
162
171
|
|
|
163
|
-
|
|
164
|
-
|
|
172
|
+
if pair_data is None:
|
|
173
|
+
continue
|
|
165
174
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
175
|
+
# Skip entries without a response (incomplete)
|
|
176
|
+
if not await self._is_stream_complete(pair_data.id, cursor=cursor):
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
# Skip expired entries
|
|
180
|
+
if await self._is_pair_expired(pair_data, cursor=cursor):
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
# Skip soft-deleted entries
|
|
184
|
+
if self.is_soft_deleted(pair_data):
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
final_pairs.append(pair_data)
|
|
188
|
+
|
|
189
|
+
pairs_with_streams: List[Entry] = []
|
|
190
|
+
|
|
191
|
+
# Only restore response streams from cache
|
|
192
|
+
for pair in final_pairs:
|
|
193
|
+
pairs_with_streams.append(
|
|
194
|
+
replace(
|
|
195
|
+
pair,
|
|
196
|
+
response=replace(
|
|
197
|
+
pair.response,
|
|
198
|
+
stream=self._stream_data_from_cache(pair.id.bytes),
|
|
199
|
+
),
|
|
200
|
+
)
|
|
191
201
|
)
|
|
192
|
-
|
|
193
|
-
return pairs_with_streams
|
|
202
|
+
return pairs_with_streams
|
|
194
203
|
|
|
195
204
|
async def update_entry(
|
|
196
205
|
self,
|
|
197
206
|
id: uuid.UUID,
|
|
198
207
|
new_pair: Union[Entry, Callable[[Entry], Entry]],
|
|
199
208
|
) -> Optional[Entry]:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if result is None:
|
|
206
|
-
return None
|
|
209
|
+
async with self._lock:
|
|
210
|
+
connection = await self._ensure_connection()
|
|
211
|
+
cursor = await connection.cursor()
|
|
212
|
+
await cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
|
|
213
|
+
result = await cursor.fetchone()
|
|
207
214
|
|
|
208
|
-
|
|
215
|
+
if result is None:
|
|
216
|
+
return None
|
|
209
217
|
|
|
210
|
-
|
|
211
|
-
if not isinstance(pair, Entry) or pair.response is None:
|
|
212
|
-
return None
|
|
218
|
+
pair = unpack(result[0], kind="pair")
|
|
213
219
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
complete_pair = new_pair(pair)
|
|
220
|
+
# Skip entries without a response (incomplete)
|
|
221
|
+
if not isinstance(pair, Entry) or pair.response is None:
|
|
222
|
+
return None
|
|
218
223
|
|
|
219
|
-
|
|
220
|
-
|
|
224
|
+
if isinstance(new_pair, Entry):
|
|
225
|
+
complete_pair = new_pair
|
|
226
|
+
else:
|
|
227
|
+
complete_pair = new_pair(pair)
|
|
221
228
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
(pack(complete_pair, kind="pair"), id.bytes),
|
|
225
|
-
)
|
|
229
|
+
if pair.id != complete_pair.id:
|
|
230
|
+
raise ValueError("Pair ID mismatch")
|
|
226
231
|
|
|
227
|
-
if pair.cache_key != complete_pair.cache_key:
|
|
228
232
|
await cursor.execute(
|
|
229
|
-
"UPDATE entries SET
|
|
230
|
-
(complete_pair
|
|
233
|
+
"UPDATE entries SET data = ? WHERE id = ?",
|
|
234
|
+
(pack(complete_pair, kind="pair"), id.bytes),
|
|
231
235
|
)
|
|
232
236
|
|
|
233
|
-
|
|
237
|
+
if pair.cache_key != complete_pair.cache_key:
|
|
238
|
+
await cursor.execute(
|
|
239
|
+
"UPDATE entries SET cache_key = ? WHERE id = ?",
|
|
240
|
+
(complete_pair.cache_key, complete_pair.id.bytes),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
await connection.commit()
|
|
234
244
|
|
|
235
|
-
|
|
245
|
+
return complete_pair
|
|
236
246
|
|
|
237
247
|
async def remove_entry(self, id: uuid.UUID) -> None:
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
248
|
+
async with self._lock:
|
|
249
|
+
connection = await self._ensure_connection()
|
|
250
|
+
cursor = await connection.cursor()
|
|
251
|
+
await cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
|
|
252
|
+
result = await cursor.fetchone()
|
|
242
253
|
|
|
243
|
-
|
|
244
|
-
|
|
254
|
+
if result is None:
|
|
255
|
+
return None
|
|
245
256
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
257
|
+
pair = unpack(result[0], kind="pair")
|
|
258
|
+
await self._soft_delete_pair(pair, cursor)
|
|
259
|
+
await connection.commit()
|
|
249
260
|
|
|
250
261
|
async def close(self) -> None:
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
262
|
+
async with self._lock:
|
|
263
|
+
if self.connection is not None:
|
|
264
|
+
await self.connection.close()
|
|
265
|
+
self.connection = None
|
|
254
266
|
|
|
255
267
|
async def _is_stream_complete(self, pair_id: uuid.UUID, cursor: anysqlite.Cursor) -> bool:
|
|
256
268
|
# Check if there's a completion marker (chunk_number = -1) for response stream
|
|
@@ -363,36 +375,40 @@ try:
|
|
|
363
375
|
"""
|
|
364
376
|
await cursor.execute("DELETE FROM streams WHERE entry_id = ?", (entry_id,))
|
|
365
377
|
|
|
366
|
-
async def
|
|
378
|
+
async def _save_stream_unlocked(
|
|
367
379
|
self,
|
|
368
380
|
stream: AsyncIterator[bytes],
|
|
369
381
|
entry_id: bytes,
|
|
370
382
|
) -> AsyncIterator[bytes]:
|
|
371
383
|
"""
|
|
372
384
|
Wrapper around an async iterator that also saves the response data to the cache in chunks.
|
|
385
|
+
|
|
386
|
+
Note: This method assumes the caller has already acquired the lock.
|
|
373
387
|
"""
|
|
374
388
|
chunk_number = 0
|
|
375
389
|
content_length = 0
|
|
376
390
|
async for chunk in stream:
|
|
377
391
|
content_length += len(chunk)
|
|
392
|
+
async with self._lock:
|
|
393
|
+
connection = await self._ensure_connection()
|
|
394
|
+
cursor = await connection.cursor()
|
|
395
|
+
await cursor.execute(
|
|
396
|
+
"INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
|
|
397
|
+
(entry_id, chunk_number, chunk),
|
|
398
|
+
)
|
|
399
|
+
await connection.commit()
|
|
400
|
+
chunk_number += 1
|
|
401
|
+
yield chunk
|
|
402
|
+
|
|
403
|
+
async with self._lock:
|
|
404
|
+
# Mark end of stream with chunk_number = -1
|
|
378
405
|
connection = await self._ensure_connection()
|
|
379
406
|
cursor = await connection.cursor()
|
|
380
407
|
await cursor.execute(
|
|
381
408
|
"INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
|
|
382
|
-
(entry_id,
|
|
409
|
+
(entry_id, self._COMPLETE_CHUNK_NUMBER, b""),
|
|
383
410
|
)
|
|
384
411
|
await connection.commit()
|
|
385
|
-
chunk_number += 1
|
|
386
|
-
yield chunk
|
|
387
|
-
|
|
388
|
-
# Mark end of stream with chunk_number = -1
|
|
389
|
-
connection = await self._ensure_connection()
|
|
390
|
-
cursor = await connection.cursor()
|
|
391
|
-
await cursor.execute(
|
|
392
|
-
"INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
|
|
393
|
-
(entry_id, self._COMPLETE_CHUNK_NUMBER, b""),
|
|
394
|
-
)
|
|
395
|
-
await connection.commit()
|
|
396
412
|
|
|
397
413
|
async def _stream_data_from_cache(
|
|
398
414
|
self,
|
|
@@ -403,23 +419,24 @@ try:
|
|
|
403
419
|
"""
|
|
404
420
|
chunk_number = 0
|
|
405
421
|
|
|
406
|
-
connection = await self._ensure_connection()
|
|
407
422
|
while True:
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
(
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
+
async with self._lock:
|
|
424
|
+
connection = await self._ensure_connection()
|
|
425
|
+
cursor = await connection.cursor()
|
|
426
|
+
await cursor.execute(
|
|
427
|
+
"SELECT chunk_data FROM streams WHERE entry_id = ? AND chunk_number = ?",
|
|
428
|
+
(entry_id, chunk_number),
|
|
429
|
+
)
|
|
430
|
+
result = await cursor.fetchone()
|
|
431
|
+
|
|
432
|
+
if result is None:
|
|
433
|
+
break
|
|
434
|
+
chunk = result[0]
|
|
435
|
+
# chunk_number = -1 is the completion marker with empty data
|
|
436
|
+
if chunk == b"":
|
|
437
|
+
break
|
|
438
|
+
yield chunk
|
|
439
|
+
chunk_number += 1
|
|
423
440
|
|
|
424
441
|
except ImportError:
|
|
425
442
|
|
|
@@ -35,6 +35,7 @@ BATCH_CLEANUP_CHUNK_SIZE = 200
|
|
|
35
35
|
|
|
36
36
|
try:
|
|
37
37
|
import sqlite3
|
|
38
|
+
from threading import RLock
|
|
38
39
|
|
|
39
40
|
class SyncSqliteStorage(SyncBaseStorage):
|
|
40
41
|
_COMPLETE_CHUNK_NUMBER = -1
|
|
@@ -55,9 +56,15 @@ try:
|
|
|
55
56
|
# When this storage instance was created. Used to delay the first cleanup.
|
|
56
57
|
self._start_time = time.time()
|
|
57
58
|
self._initialized = False
|
|
59
|
+
self._lock = RLock()
|
|
58
60
|
|
|
59
61
|
def _ensure_connection(self) -> sqlite3.Connection:
|
|
60
|
-
"""
|
|
62
|
+
"""
|
|
63
|
+
Ensure connection is established and database is initialized.
|
|
64
|
+
|
|
65
|
+
Note: This method assumes the caller has already acquired the lock.
|
|
66
|
+
"""
|
|
67
|
+
|
|
61
68
|
if self.connection is None:
|
|
62
69
|
# Create cache directory and resolve full path on first connection
|
|
63
70
|
parent = self.database_path.parent if self.database_path.parent != Path(".") else None
|
|
@@ -106,151 +113,156 @@ try:
|
|
|
106
113
|
) -> Entry:
|
|
107
114
|
key_bytes = key.encode("utf-8")
|
|
108
115
|
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
with self._lock:
|
|
117
|
+
connection = self._ensure_connection()
|
|
118
|
+
cursor = connection.cursor()
|
|
111
119
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
# Create a new entry directly with both request and response
|
|
121
|
+
pair_id = id_ if id_ is not None else uuid.uuid4()
|
|
122
|
+
pair_meta = EntryMeta(
|
|
123
|
+
created_at=time.time(),
|
|
124
|
+
)
|
|
117
125
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
assert isinstance(response.stream, (Iterator, Iterable))
|
|
127
|
+
response_with_stream = replace(
|
|
128
|
+
response,
|
|
129
|
+
stream=self._save_stream_unlocked(response.stream, pair_id.bytes),
|
|
130
|
+
)
|
|
123
131
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
132
|
+
complete_entry = Entry(
|
|
133
|
+
id=pair_id,
|
|
134
|
+
request=request,
|
|
135
|
+
response=response_with_stream,
|
|
136
|
+
meta=pair_meta,
|
|
137
|
+
cache_key=key_bytes,
|
|
138
|
+
)
|
|
131
139
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
# Insert the complete entry into the database
|
|
141
|
+
cursor.execute(
|
|
142
|
+
"INSERT INTO entries (id, cache_key, data, created_at, deleted_at) VALUES (?, ?, ?, ?, ?)",
|
|
143
|
+
(pair_id.bytes, key_bytes, pack(complete_entry, kind="pair"), pair_meta.created_at, None),
|
|
144
|
+
)
|
|
145
|
+
connection.commit()
|
|
138
146
|
|
|
139
|
-
|
|
147
|
+
return complete_entry
|
|
140
148
|
|
|
141
149
|
def get_entries(self, key: str) -> List[Entry]:
|
|
142
150
|
final_pairs: List[Entry] = []
|
|
143
151
|
|
|
144
152
|
now = time.time()
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
with self._lock:
|
|
154
|
+
if now - self.last_cleanup >= BATCH_CLEANUP_INTERVAL:
|
|
155
|
+
try:
|
|
156
|
+
self._batch_cleanup()
|
|
157
|
+
except Exception:
|
|
158
|
+
# don't let cleanup prevent reads; failures are non-fatal
|
|
159
|
+
pass
|
|
151
160
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
connection = self._ensure_connection()
|
|
162
|
+
cursor = connection.cursor()
|
|
163
|
+
# Query entries directly by cache_key
|
|
164
|
+
cursor.execute(
|
|
165
|
+
"SELECT id, data FROM entries WHERE cache_key = ?",
|
|
166
|
+
(key.encode("utf-8"),),
|
|
167
|
+
)
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
for row in cursor.fetchall():
|
|
170
|
+
pair_data = unpack(row[1], kind="pair")
|
|
162
171
|
|
|
163
|
-
|
|
164
|
-
|
|
172
|
+
if pair_data is None:
|
|
173
|
+
continue
|
|
165
174
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
175
|
+
# Skip entries without a response (incomplete)
|
|
176
|
+
if not self._is_stream_complete(pair_data.id, cursor=cursor):
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
# Skip expired entries
|
|
180
|
+
if self._is_pair_expired(pair_data, cursor=cursor):
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
# Skip soft-deleted entries
|
|
184
|
+
if self.is_soft_deleted(pair_data):
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
final_pairs.append(pair_data)
|
|
188
|
+
|
|
189
|
+
pairs_with_streams: List[Entry] = []
|
|
190
|
+
|
|
191
|
+
# Only restore response streams from cache
|
|
192
|
+
for pair in final_pairs:
|
|
193
|
+
pairs_with_streams.append(
|
|
194
|
+
replace(
|
|
195
|
+
pair,
|
|
196
|
+
response=replace(
|
|
197
|
+
pair.response,
|
|
198
|
+
stream=self._stream_data_from_cache(pair.id.bytes),
|
|
199
|
+
),
|
|
200
|
+
)
|
|
191
201
|
)
|
|
192
|
-
|
|
193
|
-
return pairs_with_streams
|
|
202
|
+
return pairs_with_streams
|
|
194
203
|
|
|
195
204
|
def update_entry(
|
|
196
205
|
self,
|
|
197
206
|
id: uuid.UUID,
|
|
198
207
|
new_pair: Union[Entry, Callable[[Entry], Entry]],
|
|
199
208
|
) -> Optional[Entry]:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if result is None:
|
|
206
|
-
return None
|
|
209
|
+
with self._lock:
|
|
210
|
+
connection = self._ensure_connection()
|
|
211
|
+
cursor = connection.cursor()
|
|
212
|
+
cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
|
|
213
|
+
result = cursor.fetchone()
|
|
207
214
|
|
|
208
|
-
|
|
215
|
+
if result is None:
|
|
216
|
+
return None
|
|
209
217
|
|
|
210
|
-
|
|
211
|
-
if not isinstance(pair, Entry) or pair.response is None:
|
|
212
|
-
return None
|
|
218
|
+
pair = unpack(result[0], kind="pair")
|
|
213
219
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
complete_pair = new_pair(pair)
|
|
220
|
+
# Skip entries without a response (incomplete)
|
|
221
|
+
if not isinstance(pair, Entry) or pair.response is None:
|
|
222
|
+
return None
|
|
218
223
|
|
|
219
|
-
|
|
220
|
-
|
|
224
|
+
if isinstance(new_pair, Entry):
|
|
225
|
+
complete_pair = new_pair
|
|
226
|
+
else:
|
|
227
|
+
complete_pair = new_pair(pair)
|
|
221
228
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
(pack(complete_pair, kind="pair"), id.bytes),
|
|
225
|
-
)
|
|
229
|
+
if pair.id != complete_pair.id:
|
|
230
|
+
raise ValueError("Pair ID mismatch")
|
|
226
231
|
|
|
227
|
-
if pair.cache_key != complete_pair.cache_key:
|
|
228
232
|
cursor.execute(
|
|
229
|
-
"UPDATE entries SET
|
|
230
|
-
(complete_pair
|
|
233
|
+
"UPDATE entries SET data = ? WHERE id = ?",
|
|
234
|
+
(pack(complete_pair, kind="pair"), id.bytes),
|
|
231
235
|
)
|
|
232
236
|
|
|
233
|
-
|
|
237
|
+
if pair.cache_key != complete_pair.cache_key:
|
|
238
|
+
cursor.execute(
|
|
239
|
+
"UPDATE entries SET cache_key = ? WHERE id = ?",
|
|
240
|
+
(complete_pair.cache_key, complete_pair.id.bytes),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
connection.commit()
|
|
234
244
|
|
|
235
|
-
|
|
245
|
+
return complete_pair
|
|
236
246
|
|
|
237
247
|
def remove_entry(self, id: uuid.UUID) -> None:
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
248
|
+
with self._lock:
|
|
249
|
+
connection = self._ensure_connection()
|
|
250
|
+
cursor = connection.cursor()
|
|
251
|
+
cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
|
|
252
|
+
result = cursor.fetchone()
|
|
242
253
|
|
|
243
|
-
|
|
244
|
-
|
|
254
|
+
if result is None:
|
|
255
|
+
return None
|
|
245
256
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
257
|
+
pair = unpack(result[0], kind="pair")
|
|
258
|
+
self._soft_delete_pair(pair, cursor)
|
|
259
|
+
connection.commit()
|
|
249
260
|
|
|
250
261
|
def close(self) -> None:
|
|
251
|
-
|
|
252
|
-
self.connection
|
|
253
|
-
|
|
262
|
+
with self._lock:
|
|
263
|
+
if self.connection is not None:
|
|
264
|
+
self.connection.close()
|
|
265
|
+
self.connection = None
|
|
254
266
|
|
|
255
267
|
def _is_stream_complete(self, pair_id: uuid.UUID, cursor: sqlite3.Cursor) -> bool:
|
|
256
268
|
# Check if there's a completion marker (chunk_number = -1) for response stream
|
|
@@ -363,36 +375,40 @@ try:
|
|
|
363
375
|
"""
|
|
364
376
|
cursor.execute("DELETE FROM streams WHERE entry_id = ?", (entry_id,))
|
|
365
377
|
|
|
366
|
-
def
|
|
378
|
+
def _save_stream_unlocked(
|
|
367
379
|
self,
|
|
368
380
|
stream: Iterator[bytes],
|
|
369
381
|
entry_id: bytes,
|
|
370
382
|
) -> Iterator[bytes]:
|
|
371
383
|
"""
|
|
372
384
|
Wrapper around an async iterator that also saves the response data to the cache in chunks.
|
|
385
|
+
|
|
386
|
+
Note: This method assumes the caller has already acquired the lock.
|
|
373
387
|
"""
|
|
374
388
|
chunk_number = 0
|
|
375
389
|
content_length = 0
|
|
376
390
|
for chunk in stream:
|
|
377
391
|
content_length += len(chunk)
|
|
392
|
+
with self._lock:
|
|
393
|
+
connection = self._ensure_connection()
|
|
394
|
+
cursor = connection.cursor()
|
|
395
|
+
cursor.execute(
|
|
396
|
+
"INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
|
|
397
|
+
(entry_id, chunk_number, chunk),
|
|
398
|
+
)
|
|
399
|
+
connection.commit()
|
|
400
|
+
chunk_number += 1
|
|
401
|
+
yield chunk
|
|
402
|
+
|
|
403
|
+
with self._lock:
|
|
404
|
+
# Mark end of stream with chunk_number = -1
|
|
378
405
|
connection = self._ensure_connection()
|
|
379
406
|
cursor = connection.cursor()
|
|
380
407
|
cursor.execute(
|
|
381
408
|
"INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
|
|
382
|
-
(entry_id,
|
|
409
|
+
(entry_id, self._COMPLETE_CHUNK_NUMBER, b""),
|
|
383
410
|
)
|
|
384
411
|
connection.commit()
|
|
385
|
-
chunk_number += 1
|
|
386
|
-
yield chunk
|
|
387
|
-
|
|
388
|
-
# Mark end of stream with chunk_number = -1
|
|
389
|
-
connection = self._ensure_connection()
|
|
390
|
-
cursor = connection.cursor()
|
|
391
|
-
cursor.execute(
|
|
392
|
-
"INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
|
|
393
|
-
(entry_id, self._COMPLETE_CHUNK_NUMBER, b""),
|
|
394
|
-
)
|
|
395
|
-
connection.commit()
|
|
396
412
|
|
|
397
413
|
def _stream_data_from_cache(
|
|
398
414
|
self,
|
|
@@ -403,23 +419,24 @@ try:
|
|
|
403
419
|
"""
|
|
404
420
|
chunk_number = 0
|
|
405
421
|
|
|
406
|
-
connection = self._ensure_connection()
|
|
407
422
|
while True:
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
(
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
+
with self._lock:
|
|
424
|
+
connection = self._ensure_connection()
|
|
425
|
+
cursor = connection.cursor()
|
|
426
|
+
cursor.execute(
|
|
427
|
+
"SELECT chunk_data FROM streams WHERE entry_id = ? AND chunk_number = ?",
|
|
428
|
+
(entry_id, chunk_number),
|
|
429
|
+
)
|
|
430
|
+
result = cursor.fetchone()
|
|
431
|
+
|
|
432
|
+
if result is None:
|
|
433
|
+
break
|
|
434
|
+
chunk = result[0]
|
|
435
|
+
# chunk_number = -1 is the completion marker with empty data
|
|
436
|
+
if chunk == b"":
|
|
437
|
+
break
|
|
438
|
+
yield chunk
|
|
439
|
+
chunk_number += 1
|
|
423
440
|
|
|
424
441
|
except ImportError:
|
|
425
442
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|