cachu 0.2.4__py3-none-any.whl → 0.2.5__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.
cachu/backends/sqlite.py CHANGED
@@ -8,7 +8,8 @@ import time
8
8
  from collections.abc import AsyncIterator, Iterator
9
9
  from typing import TYPE_CHECKING, Any
10
10
 
11
- from . import NO_VALUE, AsyncBackend, Backend
11
+ from ..mutex import AsyncCacheMutex, AsyncioMutex, CacheMutex, ThreadingMutex
12
+ from . import NO_VALUE, Backend
12
13
 
13
14
  if TYPE_CHECKING:
14
15
  import aiosqlite
@@ -28,18 +29,36 @@ def _get_aiosqlite_module() -> Any:
28
29
 
29
30
 
30
31
  class SqliteBackend(Backend):
31
- """SQLite file-based cache backend.
32
+ """Unified SQLite file-based cache backend with both sync and async interfaces.
32
33
  """
33
34
 
34
35
  def __init__(self, filepath: str) -> None:
35
36
  self._filepath = filepath
36
- self._lock = threading.RLock()
37
- self._init_db()
37
+ self._sync_lock = threading.RLock()
38
+ self._async_lock: asyncio.Lock | None = None
39
+ self._async_write_lock: asyncio.Lock | None = None
40
+ self._async_connection: aiosqlite.Connection | None = None
41
+ self._async_initialized = False
42
+ self._init_sync_db()
43
+
44
+ def _get_async_lock(self) -> asyncio.Lock:
45
+ """Lazy-create async init lock (must be called from async context).
46
+ """
47
+ if self._async_lock is None:
48
+ self._async_lock = asyncio.Lock()
49
+ return self._async_lock
50
+
51
+ def _get_async_write_lock(self) -> asyncio.Lock:
52
+ """Lazy-create async write lock (must be called from async context).
53
+ """
54
+ if self._async_write_lock is None:
55
+ self._async_write_lock = asyncio.Lock()
56
+ return self._async_write_lock
38
57
 
39
- def _init_db(self) -> None:
40
- """Initialize database schema.
58
+ def _init_sync_db(self) -> None:
59
+ """Initialize sync database schema.
41
60
  """
42
- with self._lock:
61
+ with self._sync_lock:
43
62
  conn = sqlite3.connect(self._filepath)
44
63
  try:
45
64
  conn.execute("""
@@ -58,16 +77,65 @@ class SqliteBackend(Backend):
58
77
  finally:
59
78
  conn.close()
60
79
 
61
- def _get_connection(self) -> sqlite3.Connection:
62
- """Get a database connection.
80
+ async def _ensure_async_initialized(self) -> 'aiosqlite.Connection':
81
+ """Ensure async database is initialized and return connection.
82
+ """
83
+ async with self._get_async_lock():
84
+ if self._async_connection is None:
85
+ aiosqlite = _get_aiosqlite_module()
86
+ self._async_connection = await aiosqlite.connect(self._filepath)
87
+ await self._async_connection.execute('PRAGMA journal_mode=WAL')
88
+ await self._async_connection.execute('PRAGMA busy_timeout=5000')
89
+
90
+ if not self._async_initialized:
91
+ await self._async_connection.execute("""
92
+ CREATE TABLE IF NOT EXISTS cache (
93
+ key TEXT PRIMARY KEY,
94
+ value BLOB NOT NULL,
95
+ created_at REAL NOT NULL,
96
+ expires_at REAL NOT NULL
97
+ )
98
+ """)
99
+ await self._async_connection.execute("""
100
+ CREATE INDEX IF NOT EXISTS idx_cache_expires
101
+ ON cache(expires_at)
102
+ """)
103
+ await self._async_connection.commit()
104
+ self._async_initialized = True
105
+
106
+ return self._async_connection
107
+
108
+ def _get_sync_connection(self) -> sqlite3.Connection:
109
+ """Get a sync database connection.
63
110
  """
64
111
  return sqlite3.connect(self._filepath)
65
112
 
113
+ def _fnmatch_to_glob(self, pattern: str) -> str:
114
+ """Convert fnmatch pattern to SQLite GLOB pattern.
115
+ """
116
+ return pattern
117
+
118
+ def _schedule_async_delete(self, key: str) -> None:
119
+ """Schedule a background deletion task (fire-and-forget).
120
+ """
121
+ async def _delete() -> None:
122
+ try:
123
+ async with self._get_async_write_lock():
124
+ conn = await self._ensure_async_initialized()
125
+ await conn.execute('DELETE FROM cache WHERE key = ?', (key,))
126
+ await conn.commit()
127
+ except Exception:
128
+ pass
129
+
130
+ asyncio.create_task(_delete())
131
+
132
+ # ===== Sync interface =====
133
+
66
134
  def get(self, key: str) -> Any:
67
135
  """Get value by key. Returns NO_VALUE if not found or expired.
68
136
  """
69
- with self._lock:
70
- conn = self._get_connection()
137
+ with self._sync_lock:
138
+ conn = self._get_sync_connection()
71
139
  try:
72
140
  cursor = conn.execute(
73
141
  'SELECT value, expires_at FROM cache WHERE key = ?',
@@ -93,8 +161,8 @@ class SqliteBackend(Backend):
93
161
  def get_with_metadata(self, key: str) -> tuple[Any, float | None]:
94
162
  """Get value and creation timestamp. Returns (NO_VALUE, None) if not found.
95
163
  """
96
- with self._lock:
97
- conn = self._get_connection()
164
+ with self._sync_lock:
165
+ conn = self._get_sync_connection()
98
166
  try:
99
167
  cursor = conn.execute(
100
168
  'SELECT value, created_at, expires_at FROM cache WHERE key = ?',
@@ -123,8 +191,8 @@ class SqliteBackend(Backend):
123
191
  now = time.time()
124
192
  value_blob = pickle.dumps(value)
125
193
 
126
- with self._lock:
127
- conn = self._get_connection()
194
+ with self._sync_lock:
195
+ conn = self._get_sync_connection()
128
196
  try:
129
197
  conn.execute(
130
198
  """INSERT OR REPLACE INTO cache (key, value, created_at, expires_at)
@@ -138,8 +206,8 @@ class SqliteBackend(Backend):
138
206
  def delete(self, key: str) -> None:
139
207
  """Delete value by key.
140
208
  """
141
- with self._lock:
142
- conn = self._get_connection()
209
+ with self._sync_lock:
210
+ conn = self._get_sync_connection()
143
211
  try:
144
212
  conn.execute('DELETE FROM cache WHERE key = ?', (key,))
145
213
  conn.commit()
@@ -151,8 +219,8 @@ class SqliteBackend(Backend):
151
219
  def clear(self, pattern: str | None = None) -> int:
152
220
  """Clear entries matching pattern. Returns count of cleared entries.
153
221
  """
154
- with self._lock:
155
- conn = self._get_connection()
222
+ with self._sync_lock:
223
+ conn = self._get_sync_connection()
156
224
  try:
157
225
  if pattern is None:
158
226
  cursor = conn.execute('SELECT COUNT(*) FROM cache')
@@ -180,8 +248,8 @@ class SqliteBackend(Backend):
180
248
  """
181
249
  now = time.time()
182
250
 
183
- with self._lock:
184
- conn = self._get_connection()
251
+ with self._sync_lock:
252
+ conn = self._get_sync_connection()
185
253
  try:
186
254
  if pattern is None:
187
255
  cursor = conn.execute(
@@ -206,8 +274,8 @@ class SqliteBackend(Backend):
206
274
  """
207
275
  now = time.time()
208
276
 
209
- with self._lock:
210
- conn = self._get_connection()
277
+ with self._sync_lock:
278
+ conn = self._get_sync_connection()
211
279
  try:
212
280
  if pattern is None:
213
281
  cursor = conn.execute(
@@ -227,21 +295,18 @@ class SqliteBackend(Backend):
227
295
  finally:
228
296
  conn.close()
229
297
 
230
- def _fnmatch_to_glob(self, pattern: str) -> str:
231
- """Convert fnmatch pattern to SQLite GLOB pattern.
232
-
233
- fnmatch uses * and ? which are the same as SQLite GLOB.
234
- The main difference is character classes [...] which we don't use.
298
+ def get_mutex(self, key: str) -> CacheMutex:
299
+ """Get a mutex for dogpile prevention on the given key.
235
300
  """
236
- return pattern
301
+ return ThreadingMutex(f'sqlite:{self._filepath}:{key}')
237
302
 
238
303
  def cleanup_expired(self) -> int:
239
304
  """Remove expired entries. Returns count of removed entries.
240
305
  """
241
306
  now = time.time()
242
307
 
243
- with self._lock:
244
- conn = self._get_connection()
308
+ with self._sync_lock:
309
+ conn = self._get_sync_connection()
245
310
  try:
246
311
  cursor = conn.execute(
247
312
  'SELECT COUNT(*) FROM cache WHERE expires_at <= ?',
@@ -254,65 +319,13 @@ class SqliteBackend(Backend):
254
319
  finally:
255
320
  conn.close()
256
321
 
322
+ # ===== Async interface =====
257
323
 
258
- class AsyncSqliteBackend(AsyncBackend):
259
- """Async SQLite file-based cache backend using aiosqlite.
260
- """
261
-
262
- def __init__(self, filepath: str) -> None:
263
- self._filepath = filepath
264
- self._connection: aiosqlite.Connection | None = None
265
- self._init_lock = asyncio.Lock()
266
- self._write_lock = asyncio.Lock()
267
- self._initialized = False
268
-
269
- async def _ensure_initialized(self) -> 'aiosqlite.Connection':
270
- """Ensure database is initialized and return connection.
271
- """
272
- async with self._init_lock:
273
- if self._connection is None:
274
- aiosqlite = _get_aiosqlite_module()
275
- self._connection = await aiosqlite.connect(self._filepath)
276
- await self._connection.execute('PRAGMA journal_mode=WAL')
277
- await self._connection.execute('PRAGMA busy_timeout=5000')
278
-
279
- if not self._initialized:
280
- await self._connection.execute("""
281
- CREATE TABLE IF NOT EXISTS cache (
282
- key TEXT PRIMARY KEY,
283
- value BLOB NOT NULL,
284
- created_at REAL NOT NULL,
285
- expires_at REAL NOT NULL
286
- )
287
- """)
288
- await self._connection.execute("""
289
- CREATE INDEX IF NOT EXISTS idx_cache_expires
290
- ON cache(expires_at)
291
- """)
292
- await self._connection.commit()
293
- self._initialized = True
294
-
295
- return self._connection
296
-
297
- def _schedule_delete(self, key: str) -> None:
298
- """Schedule a background deletion task (fire-and-forget).
299
- """
300
- async def _delete() -> None:
301
- try:
302
- async with self._write_lock:
303
- conn = await self._ensure_initialized()
304
- await conn.execute('DELETE FROM cache WHERE key = ?', (key,))
305
- await conn.commit()
306
- except Exception:
307
- pass
308
-
309
- asyncio.create_task(_delete())
310
-
311
- async def get(self, key: str) -> Any:
312
- """Get value by key. Returns NO_VALUE if not found or expired.
324
+ async def aget(self, key: str) -> Any:
325
+ """Async get value by key. Returns NO_VALUE if not found or expired.
313
326
  """
314
327
  try:
315
- conn = await self._ensure_initialized()
328
+ conn = await self._ensure_async_initialized()
316
329
  cursor = await conn.execute(
317
330
  'SELECT value, expires_at FROM cache WHERE key = ?',
318
331
  (key,),
@@ -324,18 +337,18 @@ class AsyncSqliteBackend(AsyncBackend):
324
337
 
325
338
  value_blob, expires_at = row
326
339
  if time.time() > expires_at:
327
- self._schedule_delete(key)
340
+ self._schedule_async_delete(key)
328
341
  return NO_VALUE
329
342
 
330
343
  return pickle.loads(value_blob)
331
344
  except Exception:
332
345
  return NO_VALUE
333
346
 
334
- async def get_with_metadata(self, key: str) -> tuple[Any, float | None]:
335
- """Get value and creation timestamp. Returns (NO_VALUE, None) if not found.
347
+ async def aget_with_metadata(self, key: str) -> tuple[Any, float | None]:
348
+ """Async get value and creation timestamp. Returns (NO_VALUE, None) if not found.
336
349
  """
337
350
  try:
338
- conn = await self._ensure_initialized()
351
+ conn = await self._ensure_async_initialized()
339
352
  cursor = await conn.execute(
340
353
  'SELECT value, created_at, expires_at FROM cache WHERE key = ?',
341
354
  (key,),
@@ -347,21 +360,21 @@ class AsyncSqliteBackend(AsyncBackend):
347
360
 
348
361
  value_blob, created_at, expires_at = row
349
362
  if time.time() > expires_at:
350
- self._schedule_delete(key)
363
+ self._schedule_async_delete(key)
351
364
  return NO_VALUE, None
352
365
 
353
366
  return pickle.loads(value_blob), created_at
354
367
  except Exception:
355
368
  return NO_VALUE, None
356
369
 
357
- async def set(self, key: str, value: Any, ttl: int) -> None:
358
- """Set value with TTL in seconds.
370
+ async def aset(self, key: str, value: Any, ttl: int) -> None:
371
+ """Async set value with TTL in seconds.
359
372
  """
360
373
  now = time.time()
361
374
  value_blob = pickle.dumps(value)
362
375
 
363
- async with self._write_lock:
364
- conn = await self._ensure_initialized()
376
+ async with self._get_async_write_lock():
377
+ conn = await self._ensure_async_initialized()
365
378
  await conn.execute(
366
379
  """INSERT OR REPLACE INTO cache (key, value, created_at, expires_at)
367
380
  VALUES (?, ?, ?, ?)""",
@@ -369,23 +382,23 @@ class AsyncSqliteBackend(AsyncBackend):
369
382
  )
370
383
  await conn.commit()
371
384
 
372
- async def delete(self, key: str) -> None:
373
- """Delete value by key.
385
+ async def adelete(self, key: str) -> None:
386
+ """Async delete value by key.
374
387
  """
375
- async with self._write_lock:
388
+ async with self._get_async_write_lock():
376
389
  try:
377
- conn = await self._ensure_initialized()
390
+ conn = await self._ensure_async_initialized()
378
391
  await conn.execute('DELETE FROM cache WHERE key = ?', (key,))
379
392
  await conn.commit()
380
393
  except Exception:
381
394
  pass
382
395
 
383
- async def clear(self, pattern: str | None = None) -> int:
384
- """Clear entries matching pattern. Returns count of cleared entries.
396
+ async def aclear(self, pattern: str | None = None) -> int:
397
+ """Async clear entries matching pattern. Returns count of cleared entries.
385
398
  """
386
- async with self._write_lock:
399
+ async with self._get_async_write_lock():
387
400
  try:
388
- conn = await self._ensure_initialized()
401
+ conn = await self._ensure_async_initialized()
389
402
  if pattern is None:
390
403
  cursor = await conn.execute('SELECT COUNT(*) FROM cache')
391
404
  row = await cursor.fetchone()
@@ -407,11 +420,11 @@ class AsyncSqliteBackend(AsyncBackend):
407
420
  except Exception:
408
421
  return 0
409
422
 
410
- async def keys(self, pattern: str | None = None) -> AsyncIterator[str]:
411
- """Iterate over keys matching pattern.
423
+ async def akeys(self, pattern: str | None = None) -> AsyncIterator[str]:
424
+ """Async iterate over keys matching pattern.
412
425
  """
413
426
  now = time.time()
414
- conn = await self._ensure_initialized()
427
+ conn = await self._ensure_async_initialized()
415
428
 
416
429
  if pattern is None:
417
430
  cursor = await conn.execute(
@@ -430,13 +443,13 @@ class AsyncSqliteBackend(AsyncBackend):
430
443
  for key in all_keys:
431
444
  yield key
432
445
 
433
- async def count(self, pattern: str | None = None) -> int:
434
- """Count keys matching pattern.
446
+ async def acount(self, pattern: str | None = None) -> int:
447
+ """Async count keys matching pattern.
435
448
  """
436
449
  now = time.time()
437
450
 
438
451
  try:
439
- conn = await self._ensure_initialized()
452
+ conn = await self._ensure_async_initialized()
440
453
  if pattern is None:
441
454
  cursor = await conn.execute(
442
455
  'SELECT COUNT(*) FROM cache WHERE expires_at > ?',
@@ -454,18 +467,18 @@ class AsyncSqliteBackend(AsyncBackend):
454
467
  except Exception:
455
468
  return 0
456
469
 
457
- def _fnmatch_to_glob(self, pattern: str) -> str:
458
- """Convert fnmatch pattern to SQLite GLOB pattern.
470
+ def get_async_mutex(self, key: str) -> AsyncCacheMutex:
471
+ """Get an async mutex for dogpile prevention on the given key.
459
472
  """
460
- return pattern
473
+ return AsyncioMutex(f'sqlite:{self._filepath}:{key}')
461
474
 
462
- async def cleanup_expired(self) -> int:
463
- """Remove expired entries. Returns count of removed entries.
475
+ async def acleanup_expired(self) -> int:
476
+ """Async remove expired entries. Returns count of removed entries.
464
477
  """
465
478
  now = time.time()
466
479
 
467
- async with self._write_lock:
468
- conn = await self._ensure_initialized()
480
+ async with self._get_async_write_lock():
481
+ conn = await self._ensure_async_initialized()
469
482
  cursor = await conn.execute(
470
483
  'SELECT COUNT(*) FROM cache WHERE expires_at <= ?',
471
484
  (now,),
@@ -476,10 +489,37 @@ class AsyncSqliteBackend(AsyncBackend):
476
489
  await conn.commit()
477
490
  return count
478
491
 
479
- async def close(self) -> None:
480
- """Close the database connection.
492
+ # ===== Lifecycle =====
493
+
494
+ def _close_async_connection_sync(self) -> None:
495
+ """Forcefully close async connection from sync context.
496
+
497
+ This accesses aiosqlite internals as there's no public sync close API.
498
+ """
499
+ if self._async_connection is None:
500
+ return
501
+
502
+ conn = self._async_connection
503
+ self._async_connection = None
504
+ self._async_initialized = False
505
+
506
+ try:
507
+ conn._running = False
508
+ if hasattr(conn, '_connection') and conn._connection:
509
+ conn._connection.close()
510
+ except Exception:
511
+ pass
512
+
513
+ def close(self) -> None:
514
+ """Close all backend resources from sync context.
515
+ """
516
+ self._close_async_connection_sync()
517
+
518
+ async def aclose(self) -> None:
519
+ """Close all backend resources from async context.
481
520
  """
482
- if self._connection is not None:
483
- await self._connection.close()
484
- self._connection = None
485
- self._initialized = False
521
+ if self._async_connection is not None:
522
+ conn = self._async_connection
523
+ self._async_connection = None
524
+ self._async_initialized = False
525
+ await conn.close()
cachu/config.py CHANGED
@@ -58,6 +58,7 @@ class CacheConfig:
58
58
  key_prefix: str = ''
59
59
  file_dir: str = '/tmp'
60
60
  redis_url: str = 'redis://localhost:6379/0'
61
+ lock_timeout: float = 10.0
61
62
 
62
63
 
63
64
  class ConfigRegistry:
@@ -79,6 +80,7 @@ class ConfigRegistry:
79
80
  key_prefix: str | None = None,
80
81
  file_dir: str | None = None,
81
82
  redis_url: str | None = None,
83
+ lock_timeout: float | None = None,
82
84
  ) -> CacheConfig:
83
85
  """Configure cache for a specific package.
84
86
  """
@@ -90,6 +92,7 @@ class ConfigRegistry:
90
92
  'key_prefix': key_prefix,
91
93
  'file_dir': str(file_dir) if file_dir else None,
92
94
  'redis_url': redis_url,
95
+ 'lock_timeout': lock_timeout,
93
96
  }
94
97
  updates = {k: v for k, v in updates.items() if v is not None}
95
98
 
@@ -152,6 +155,7 @@ def configure(
152
155
  key_prefix: str | None = None,
153
156
  file_dir: str | None = None,
154
157
  redis_url: str | None = None,
158
+ lock_timeout: float | None = None,
155
159
  ) -> CacheConfig:
156
160
  """Configure cache settings for the caller's package.
157
161
 
@@ -163,12 +167,14 @@ def configure(
163
167
  key_prefix: Prefix for all cache keys (for versioning/debugging)
164
168
  file_dir: Directory for file-based caches
165
169
  redis_url: Redis connection URL (e.g., 'redis://localhost:6379/0')
170
+ lock_timeout: Timeout for distributed locks in seconds (default: 10.0)
166
171
  """
167
172
  return _registry.configure(
168
173
  backend=backend,
169
174
  key_prefix=key_prefix,
170
175
  file_dir=str(file_dir) if file_dir else None,
171
176
  redis_url=redis_url,
177
+ lock_timeout=lock_timeout,
172
178
  )
173
179
 
174
180