cinchdb 0.1.14__py3-none-any.whl → 0.1.17__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.
@@ -0,0 +1,216 @@
1
+ """Tenant activation and caching.
2
+
3
+ This module handles local caching of tenant databases with
4
+ LRU eviction and TTL management. WAL streaming is handled
5
+ at the api-server layer, not in the core library.
6
+ """
7
+
8
+ import os
9
+ import sqlite3
10
+ import hashlib
11
+ from pathlib import Path
12
+ from typing import Optional, Dict, Any
13
+ from datetime import datetime, timedelta
14
+
15
+ from cinchdb.core.connection import DatabaseConnection
16
+
17
+
18
+ class TenantCache:
19
+ """Manages local cache of activated tenant databases.
20
+
21
+ This implements an LRU cache with TTL for tenant SQLite files.
22
+ Tenants are activated on-demand from WAL replay and cached locally.
23
+ """
24
+
25
+ def __init__(self, cache_dir: Optional[Path] = None, max_size_gb: float = 10.0):
26
+ """Initialize tenant cache.
27
+
28
+ Args:
29
+ cache_dir: Directory for cached tenant databases
30
+ max_size_gb: Maximum cache size in GB before eviction
31
+ """
32
+ self.cache_dir = cache_dir or Path("/var/cache/cinchdb/tenants")
33
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
34
+ self.max_size_bytes = int(max_size_gb * 1024 * 1024 * 1024)
35
+
36
+ # Track cached tenants: tenant_key -> (path, version, last_access)
37
+ self.cache_index: Dict[str, Dict[str, Any]] = {}
38
+
39
+ def _get_cache_path(self, database: str, branch: str, tenant: str) -> Path:
40
+ """Get cache path for a tenant database.
41
+
42
+ Uses sharding to avoid too many files in one directory.
43
+ """
44
+ # Create tenant key
45
+ tenant_key = f"{database}_{branch}_{tenant}"
46
+
47
+ # Calculate shard (2-level sharding like production)
48
+ hash_val = hashlib.sha256(tenant_key.encode()).hexdigest()
49
+ shard1 = hash_val[:2]
50
+ shard2 = hash_val[2:4]
51
+
52
+ # Build path
53
+ cache_path = self.cache_dir / database / branch / shard1 / shard2
54
+ cache_path.mkdir(parents=True, exist_ok=True)
55
+
56
+ return cache_path / f"{tenant}.db"
57
+
58
+ def get_tenant_connection(
59
+ self,
60
+ database: str,
61
+ branch: str,
62
+ tenant: str,
63
+ wal_capture=None
64
+ ) -> DatabaseConnection:
65
+ """Get a database connection for a tenant.
66
+
67
+ If the tenant is not cached, activates it from WAL replay.
68
+
69
+ Args:
70
+ database: Database name
71
+ branch: Branch name
72
+ tenant: Tenant name
73
+ wal_capture: Optional WAL capture for streaming
74
+
75
+ Returns:
76
+ DatabaseConnection to the tenant database
77
+ """
78
+ tenant_key = f"{database}_{branch}_{tenant}"
79
+ cache_path = self._get_cache_path(database, branch, tenant)
80
+
81
+ # Check if already cached
82
+ if cache_path.exists():
83
+ # Update last access time
84
+ if tenant_key in self.cache_index:
85
+ self.cache_index[tenant_key]["last_access"] = datetime.now()
86
+
87
+ return DatabaseConnection(cache_path, wal_capture=wal_capture)
88
+
89
+ # Not cached - create empty database
90
+ # WAL streaming/activation happens at api-server layer if enabled
91
+ return self._create_empty_database(cache_path, wal_capture)
92
+
93
+ def _create_empty_database(
94
+ self,
95
+ cache_path: Path,
96
+ wal_capture=None
97
+ ) -> DatabaseConnection:
98
+ """Create an empty database (for lazy tenants or non-streaming mode).
99
+
100
+ Args:
101
+ cache_path: Path where database should be created
102
+ wal_capture: Optional WAL capture
103
+
104
+ Returns:
105
+ DatabaseConnection to the new database
106
+ """
107
+ # Create empty SQLite database
108
+ conn = sqlite3.connect(str(cache_path))
109
+
110
+ # Set up WAL mode
111
+ conn.execute("PRAGMA journal_mode = WAL")
112
+ conn.execute("PRAGMA synchronous = NORMAL")
113
+ conn.commit()
114
+ conn.close()
115
+
116
+ return DatabaseConnection(cache_path, wal_capture=wal_capture)
117
+
118
+
119
+ def _evict_if_needed(self):
120
+ """Evict least recently used tenants if cache is too large."""
121
+ # Calculate total cache size
122
+ total_size = sum(
123
+ info["size_bytes"] for info in self.cache_index.values()
124
+ )
125
+
126
+ if total_size <= self.max_size_bytes:
127
+ return # Cache is within limits
128
+
129
+ # Sort by last access time (LRU)
130
+ sorted_tenants = sorted(
131
+ self.cache_index.items(),
132
+ key=lambda x: x[1]["last_access"]
133
+ )
134
+
135
+ # Evict until under limit
136
+ for tenant_key, info in sorted_tenants:
137
+ if total_size <= self.max_size_bytes:
138
+ break
139
+
140
+ # Delete cached database
141
+ cache_path = info["path"]
142
+ if cache_path.exists():
143
+ # Delete SQLite files (main, WAL, SHM)
144
+ for suffix in ["", "-wal", "-shm"]:
145
+ file_path = Path(str(cache_path) + suffix)
146
+ if file_path.exists():
147
+ file_path.unlink()
148
+
149
+ print(f"Evicted cached tenant: {tenant_key}")
150
+
151
+ # Remove from index
152
+ total_size -= info["size_bytes"]
153
+ del self.cache_index[tenant_key]
154
+
155
+ def invalidate_tenant(self, database: str, branch: str, tenant: str):
156
+ """Invalidate a cached tenant (force re-activation on next access).
157
+
158
+ Args:
159
+ database: Database name
160
+ branch: Branch name
161
+ tenant: Tenant name
162
+ """
163
+ tenant_key = f"{database}_{branch}_{tenant}"
164
+
165
+ if tenant_key in self.cache_index:
166
+ info = self.cache_index[tenant_key]
167
+ cache_path = info["path"]
168
+
169
+ # Delete cached files
170
+ if cache_path.exists():
171
+ for suffix in ["", "-wal", "-shm"]:
172
+ file_path = Path(str(cache_path) + suffix)
173
+ if file_path.exists():
174
+ file_path.unlink()
175
+
176
+ # Remove from index
177
+ del self.cache_index[tenant_key]
178
+ print(f"Invalidated cached tenant: {tenant_key}")
179
+
180
+ def get_cache_stats(self) -> Dict[str, Any]:
181
+ """Get cache statistics.
182
+
183
+ Returns:
184
+ Dictionary with cache stats
185
+ """
186
+ total_size = sum(
187
+ info["size_bytes"] for info in self.cache_index.values()
188
+ )
189
+
190
+ return {
191
+ "cached_tenants": len(self.cache_index),
192
+ "total_size_bytes": total_size,
193
+ "total_size_mb": round(total_size / (1024 * 1024), 2),
194
+ "max_size_gb": self.max_size_bytes / (1024 * 1024 * 1024),
195
+ "cache_dir": str(self.cache_dir)
196
+ }
197
+
198
+
199
+ # Global cache instance (singleton)
200
+ _tenant_cache: Optional[TenantCache] = None
201
+
202
+
203
+ def get_tenant_cache() -> TenantCache:
204
+ """Get the global tenant cache instance.
205
+
206
+ Returns:
207
+ TenantCache singleton
208
+ """
209
+ global _tenant_cache
210
+
211
+ if _tenant_cache is None:
212
+ cache_dir = os.getenv("TENANT_CACHE_DIR", "/var/cache/cinchdb")
213
+ cache_size_gb = float(os.getenv("TENANT_CACHE_SIZE_GB", "10.0"))
214
+ _tenant_cache = TenantCache(Path(cache_dir), cache_size_gb)
215
+
216
+ return _tenant_cache
@@ -3,7 +3,6 @@
3
3
  import threading
4
4
  from pathlib import Path
5
5
  from typing import Optional, Dict
6
- from weakref import WeakValueDictionary
7
6
 
8
7
  from cinchdb.infrastructure.metadata_db import MetadataDB
9
8
 
@@ -4,7 +4,6 @@ import sqlite3
4
4
  import uuid
5
5
  from pathlib import Path
6
6
  from typing import Optional, List, Dict, Any
7
- from datetime import datetime
8
7
  import json
9
8
 
10
9
 
@@ -38,6 +37,7 @@ class MetadataDB:
38
37
  self.conn.execute("PRAGMA foreign_keys = ON")
39
38
  self.conn.execute("PRAGMA journal_mode = WAL") # Better concurrency
40
39
 
40
+
41
41
  def _create_tables(self):
42
42
  """Create the metadata tables if they don't exist."""
43
43
  with self.conn:
@@ -48,6 +48,9 @@ class MetadataDB:
48
48
  name TEXT NOT NULL UNIQUE,
49
49
  description TEXT,
50
50
  materialized BOOLEAN DEFAULT FALSE,
51
+ maintenance_mode BOOLEAN DEFAULT FALSE,
52
+ maintenance_reason TEXT,
53
+ maintenance_started_at TIMESTAMP,
51
54
  metadata JSON,
52
55
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
53
56
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@@ -63,6 +66,9 @@ class MetadataDB:
63
66
  parent_branch TEXT,
64
67
  schema_version TEXT,
65
68
  materialized BOOLEAN DEFAULT FALSE,
69
+ maintenance_mode BOOLEAN DEFAULT FALSE,
70
+ maintenance_reason TEXT,
71
+ maintenance_started_at TIMESTAMP,
66
72
  metadata JSON,
67
73
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
68
74
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -117,6 +123,19 @@ class MetadataDB:
117
123
  CREATE INDEX IF NOT EXISTS idx_tenants_shard
118
124
  ON tenants(shard)
119
125
  """)
126
+
127
+ try:
128
+ self.conn.execute("""
129
+ ALTER TABLE branches ADD COLUMN cdc_enabled BOOLEAN DEFAULT FALSE
130
+ """)
131
+ except sqlite3.OperationalError:
132
+ # Column already exists
133
+ pass
134
+
135
+ self.conn.execute("""
136
+ CREATE INDEX IF NOT EXISTS idx_branches_cdc_enabled
137
+ ON branches(cdc_enabled)
138
+ """)
120
139
 
121
140
  # Database operations
122
141
  def create_database(self, database_id: str, name: str,
@@ -362,6 +381,94 @@ class MetadataDB:
362
381
 
363
382
  return len(tenants)
364
383
 
384
+ # Maintenance Mode Methods
385
+
386
+ def set_database_maintenance(self, database_name: str, enabled: bool, reason: Optional[str] = None) -> None:
387
+ """Set maintenance mode for a database."""
388
+ with self.conn:
389
+ if enabled:
390
+ self.conn.execute("""
391
+ UPDATE databases
392
+ SET maintenance_mode = TRUE,
393
+ maintenance_reason = ?,
394
+ maintenance_started_at = CURRENT_TIMESTAMP
395
+ WHERE name = ?
396
+ """, (reason, database_name))
397
+ else:
398
+ self.conn.execute("""
399
+ UPDATE databases
400
+ SET maintenance_mode = FALSE,
401
+ maintenance_reason = NULL,
402
+ maintenance_started_at = NULL
403
+ WHERE name = ?
404
+ """, (database_name,))
405
+
406
+ def set_branch_maintenance(self, database_name: str, branch_name: str, enabled: bool, reason: Optional[str] = None) -> None:
407
+ """Set maintenance mode for a branch."""
408
+ with self.conn:
409
+ if enabled:
410
+ self.conn.execute("""
411
+ UPDATE branches
412
+ SET maintenance_mode = TRUE,
413
+ maintenance_reason = ?,
414
+ maintenance_started_at = CURRENT_TIMESTAMP
415
+ WHERE database_id = (SELECT id FROM databases WHERE name = ?)
416
+ AND name = ?
417
+ """, (reason, database_name, branch_name))
418
+ else:
419
+ self.conn.execute("""
420
+ UPDATE branches
421
+ SET maintenance_mode = FALSE,
422
+ maintenance_reason = NULL,
423
+ maintenance_started_at = NULL
424
+ WHERE database_id = (SELECT id FROM databases WHERE name = ?)
425
+ AND name = ?
426
+ """, (database_name, branch_name))
427
+
428
+ def is_database_in_maintenance(self, database_name: str) -> bool:
429
+ """Check if database is in maintenance mode."""
430
+ cursor = self.conn.execute("""
431
+ SELECT maintenance_mode FROM databases WHERE name = ?
432
+ """, (database_name,))
433
+ row = cursor.fetchone()
434
+ return bool(row and row['maintenance_mode'])
435
+
436
+ def is_branch_in_maintenance(self, database_name: str, branch_name: str) -> bool:
437
+ """Check if branch is in maintenance mode."""
438
+ cursor = self.conn.execute("""
439
+ SELECT maintenance_mode FROM branches
440
+ WHERE database_id = (SELECT id FROM databases WHERE name = ?)
441
+ AND name = ?
442
+ """, (database_name, branch_name))
443
+ row = cursor.fetchone()
444
+ return bool(row and row['maintenance_mode'])
445
+
446
+ def get_maintenance_info(self, database_name: str, branch_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
447
+ """Get maintenance mode information."""
448
+ if branch_name:
449
+ # Check branch maintenance
450
+ cursor = self.conn.execute("""
451
+ SELECT maintenance_mode, maintenance_reason, maintenance_started_at
452
+ FROM branches
453
+ WHERE database_id = (SELECT id FROM databases WHERE name = ?)
454
+ AND name = ?
455
+ """, (database_name, branch_name))
456
+ else:
457
+ # Check database maintenance
458
+ cursor = self.conn.execute("""
459
+ SELECT maintenance_mode, maintenance_reason, maintenance_started_at
460
+ FROM databases WHERE name = ?
461
+ """, (database_name,))
462
+
463
+ row = cursor.fetchone()
464
+ if row and row['maintenance_mode']:
465
+ return {
466
+ 'enabled': True,
467
+ 'reason': row['maintenance_reason'],
468
+ 'started_at': row['maintenance_started_at']
469
+ }
470
+ return None
471
+
365
472
  def close(self):
366
473
  """Close the database connection."""
367
474
  if self.conn:
@@ -4,7 +4,7 @@ import json
4
4
  import shutil
5
5
  import uuid
6
6
  from pathlib import Path
7
- from typing import List, Dict, Any, Optional
7
+ from typing import List, Dict, Any
8
8
  from datetime import datetime, timezone
9
9
 
10
10
  from cinchdb.models import Branch
@@ -11,6 +11,7 @@ from cinchdb.managers.change_tracker import ChangeTracker
11
11
  from cinchdb.managers.tenant import TenantManager
12
12
  from cinchdb.core.connection import DatabaseConnection
13
13
  from cinchdb.core.path_utils import get_tenant_db_path, get_branch_path
14
+ from cinchdb.infrastructure.metadata_connection_pool import get_metadata_db
14
15
 
15
16
  logger = logging.getLogger(__name__)
16
17
 
@@ -388,21 +389,17 @@ class ChangeApplier:
388
389
 
389
390
  def _enter_maintenance_mode(self) -> None:
390
391
  """Enter maintenance mode to block writes during schema changes."""
391
- # Create a maintenance mode file that all connections can check
392
- branch_path = get_branch_path(self.project_root, self.database, self.branch)
393
- maintenance_file = branch_path / ".maintenance_mode"
394
-
395
- with open(maintenance_file, "w") as f:
396
- import json
397
-
398
- json.dump(
399
- {
400
- "active": True,
401
- "reason": "Schema update in progress",
402
- "started_at": datetime.now().isoformat(),
403
- },
404
- f,
392
+ try:
393
+ metadata_db = get_metadata_db(self.project_root)
394
+ metadata_db.set_branch_maintenance(
395
+ self.database,
396
+ self.branch,
397
+ True,
398
+ "Schema update in progress"
405
399
  )
400
+ except Exception as e:
401
+ logger.error(f"Failed to enter maintenance mode: {e}")
402
+ # Continue anyway - we'll try to proceed with the schema update
406
403
 
407
404
  # Give time for any in-flight writes to complete
408
405
  # Can be disabled for tests via environment variable
@@ -414,11 +411,11 @@ class ChangeApplier:
414
411
 
415
412
  def _exit_maintenance_mode(self) -> None:
416
413
  """Exit maintenance mode to allow writes again."""
417
- branch_path = get_branch_path(self.project_root, self.database, self.branch)
418
- maintenance_file = branch_path / ".maintenance_mode"
419
-
420
- if maintenance_file.exists():
421
- maintenance_file.unlink()
414
+ try:
415
+ metadata_db = get_metadata_db(self.project_root)
416
+ metadata_db.set_branch_maintenance(self.database, self.branch, False)
417
+ except Exception as e:
418
+ logger.error(f"Failed to exit maintenance mode: {e}")
422
419
 
423
420
  def is_in_maintenance_mode(self) -> bool:
424
421
  """Check if branch is in maintenance mode.
@@ -426,6 +423,8 @@ class ChangeApplier:
426
423
  Returns:
427
424
  True if in maintenance mode, False otherwise
428
425
  """
429
- branch_path = get_branch_path(self.project_root, self.database, self.branch)
430
- maintenance_file = branch_path / ".maintenance_mode"
431
- return maintenance_file.exists()
426
+ try:
427
+ metadata_db = get_metadata_db(self.project_root)
428
+ return metadata_db.is_branch_in_maintenance(self.database, self.branch)
429
+ except Exception:
430
+ return False
@@ -6,7 +6,7 @@ from typing import List, Any, Optional
6
6
  from cinchdb.models import Column, Change, ChangeType
7
7
  from cinchdb.core.connection import DatabaseConnection
8
8
  from cinchdb.core.path_utils import get_tenant_db_path
9
- from cinchdb.core.maintenance import check_maintenance_mode
9
+ from cinchdb.core.maintenance_utils import check_maintenance_mode
10
10
  from cinchdb.managers.change_tracker import ChangeTracker
11
11
  from cinchdb.managers.table import TableManager
12
12