cinchdb 0.1.15__py3-none-any.whl → 0.1.18__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.
- cinchdb/cli/commands/tenant.py +50 -2
- cinchdb/config.py +4 -13
- cinchdb/core/connection.py +57 -19
- cinchdb/core/database.py +39 -12
- cinchdb/core/initializer.py +25 -40
- cinchdb/core/maintenance_utils.py +43 -0
- cinchdb/core/path_utils.py +211 -38
- cinchdb/core/tenant_activation.py +216 -0
- cinchdb/infrastructure/metadata_db.py +96 -3
- cinchdb/managers/change_applier.py +22 -23
- cinchdb/managers/codegen.py +372 -15
- cinchdb/managers/column.py +8 -5
- cinchdb/managers/data.py +17 -14
- cinchdb/managers/query.py +8 -5
- cinchdb/managers/table.py +9 -6
- cinchdb/managers/tenant.py +228 -72
- cinchdb/managers/view.py +1 -1
- cinchdb/plugins/__init__.py +7 -8
- cinchdb/plugins/base.py +55 -74
- cinchdb/plugins/decorators.py +36 -32
- cinchdb/plugins/manager.py +103 -71
- cinchdb/utils/name_validator.py +22 -12
- {cinchdb-0.1.15.dist-info → cinchdb-0.1.18.dist-info}/METADATA +39 -1
- {cinchdb-0.1.15.dist-info → cinchdb-0.1.18.dist-info}/RECORD +27 -26
- cinchdb/core/maintenance.py +0 -73
- {cinchdb-0.1.15.dist-info → cinchdb-0.1.18.dist-info}/WHEEL +0 -0
- {cinchdb-0.1.15.dist-info → cinchdb-0.1.18.dist-info}/entry_points.txt +0 -0
- {cinchdb-0.1.15.dist-info → cinchdb-0.1.18.dist-info}/licenses/LICENSE +0 -0
cinchdb/core/path_utils.py
CHANGED
@@ -1,7 +1,15 @@
|
|
1
1
|
"""Path utilities for CinchDB."""
|
2
2
|
|
3
|
+
import hashlib
|
3
4
|
from pathlib import Path
|
4
|
-
from typing import List
|
5
|
+
from typing import List, Dict, Tuple, Optional
|
6
|
+
from cinchdb.infrastructure.metadata_connection_pool import get_metadata_db
|
7
|
+
from cinchdb.utils.name_validator import validate_name
|
8
|
+
|
9
|
+
# Cache for path calculations to avoid repeated string operations
|
10
|
+
_path_cache: Dict[Tuple[str, str, str], Path] = {}
|
11
|
+
_shard_cache: Dict[str, str] = {}
|
12
|
+
_MAX_CACHE_SIZE = 10000 # Limit cache size to prevent unbounded growth
|
5
13
|
|
6
14
|
|
7
15
|
def get_project_root(start_path: Path) -> Path:
|
@@ -40,7 +48,7 @@ def get_database_path(project_root: Path, database: str) -> Path:
|
|
40
48
|
|
41
49
|
|
42
50
|
def get_branch_path(project_root: Path, database: str, branch: str) -> Path:
|
43
|
-
"""Get path to a branch directory.
|
51
|
+
"""Get path to a branch directory (now uses context root).
|
44
52
|
|
45
53
|
Args:
|
46
54
|
project_root: Project root directory
|
@@ -48,15 +56,16 @@ def get_branch_path(project_root: Path, database: str, branch: str) -> Path:
|
|
48
56
|
branch: Branch name
|
49
57
|
|
50
58
|
Returns:
|
51
|
-
Path to branch directory
|
59
|
+
Path to branch directory (context root in tenant-first structure)
|
52
60
|
"""
|
53
|
-
|
61
|
+
# In tenant-first structure, branch path is the context root
|
62
|
+
return get_context_root(project_root, database, branch)
|
54
63
|
|
55
64
|
|
56
65
|
def get_tenant_path(
|
57
66
|
project_root: Path, database: str, branch: str, tenant: str
|
58
67
|
) -> Path:
|
59
|
-
"""Get path to tenant directory.
|
68
|
+
"""Get path to tenant directory (deprecated, use get_context_root instead).
|
60
69
|
|
61
70
|
Args:
|
62
71
|
project_root: Project root directory
|
@@ -65,9 +74,9 @@ def get_tenant_path(
|
|
65
74
|
tenant: Tenant name
|
66
75
|
|
67
76
|
Returns:
|
68
|
-
Path to
|
77
|
+
Path to context root (no longer has /tenants subfolder)
|
69
78
|
"""
|
70
|
-
return get_branch_path(project_root, database, branch)
|
79
|
+
return get_branch_path(project_root, database, branch)
|
71
80
|
|
72
81
|
|
73
82
|
def get_tenant_db_path(
|
@@ -83,18 +92,20 @@ def get_tenant_db_path(
|
|
83
92
|
|
84
93
|
Returns:
|
85
94
|
Path to tenant database file in sharded directory structure
|
95
|
+
|
96
|
+
Raises:
|
97
|
+
InvalidNameError: If any name contains path traversal characters
|
86
98
|
"""
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
99
|
+
# Validate all names to prevent path traversal
|
100
|
+
# Exception: __empty__ is a system tenant and exempt from validation
|
101
|
+
validate_name(database, "database")
|
102
|
+
validate_name(branch, "branch")
|
103
|
+
if tenant != "__empty__": # System tenant exempt from validation
|
104
|
+
validate_name(tenant, "tenant")
|
92
105
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
return shard_dir / f"{tenant}.db"
|
106
|
+
context_root = get_context_root(project_root, database, branch)
|
107
|
+
shard = calculate_shard(tenant)
|
108
|
+
return context_root / shard / f"{tenant}.db"
|
98
109
|
|
99
110
|
|
100
111
|
def ensure_directory(path: Path) -> None:
|
@@ -119,10 +130,9 @@ def list_databases(project_root: Path) -> List[str]:
|
|
119
130
|
if not metadata_db_path.exists():
|
120
131
|
return []
|
121
132
|
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
return sorted(record['name'] for record in db_records)
|
133
|
+
metadata_db = get_metadata_db(project_root)
|
134
|
+
db_records = metadata_db.list_databases()
|
135
|
+
return sorted(record['name'] for record in db_records)
|
126
136
|
|
127
137
|
|
128
138
|
def list_branches(project_root: Path, database: str) -> List[str]:
|
@@ -139,13 +149,12 @@ def list_branches(project_root: Path, database: str) -> List[str]:
|
|
139
149
|
if not metadata_db_path.exists():
|
140
150
|
return []
|
141
151
|
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
return sorted(record['name'] for record in branch_records)
|
152
|
+
metadata_db = get_metadata_db(project_root)
|
153
|
+
db_info = metadata_db.get_database(database)
|
154
|
+
if not db_info:
|
155
|
+
return []
|
156
|
+
branch_records = metadata_db.list_branches(db_info['id'])
|
157
|
+
return sorted(record['name'] for record in branch_records)
|
149
158
|
|
150
159
|
|
151
160
|
def list_tenants(project_root: Path, database: str, branch: str) -> List[str]:
|
@@ -163,13 +172,177 @@ def list_tenants(project_root: Path, database: str, branch: str) -> List[str]:
|
|
163
172
|
if not metadata_db_path.exists():
|
164
173
|
return []
|
165
174
|
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
175
|
+
metadata_db = get_metadata_db(project_root)
|
176
|
+
db_info = metadata_db.get_database(database)
|
177
|
+
if not db_info:
|
178
|
+
return []
|
179
|
+
branch_info = metadata_db.get_branch(db_info['id'], branch)
|
180
|
+
if not branch_info:
|
181
|
+
return []
|
182
|
+
tenant_records = metadata_db.list_tenants(branch_info['id'])
|
183
|
+
return sorted(record['name'] for record in tenant_records)
|
184
|
+
|
185
|
+
|
186
|
+
# New tenant-first path utilities
|
187
|
+
|
188
|
+
def get_context_root(project_root: Path, database: str, branch: str) -> Path:
|
189
|
+
"""Get root directory for a database-branch context with caching.
|
190
|
+
|
191
|
+
This is the new tenant-first approach where each database-branch combination
|
192
|
+
gets its own isolated directory with tenants stored as a flat hierarchy.
|
193
|
+
|
194
|
+
Args:
|
195
|
+
project_root: Project root directory
|
196
|
+
database: Database name
|
197
|
+
branch: Branch name
|
198
|
+
|
199
|
+
Returns:
|
200
|
+
Path to context root directory (e.g., .cinchdb/prod-main/)
|
201
|
+
"""
|
202
|
+
cache_key = (str(project_root), database, branch)
|
203
|
+
|
204
|
+
if cache_key not in _path_cache:
|
205
|
+
# Check cache size and clear if needed
|
206
|
+
if len(_path_cache) >= _MAX_CACHE_SIZE:
|
207
|
+
_path_cache.clear()
|
208
|
+
|
209
|
+
_path_cache[cache_key] = project_root / ".cinchdb" / f"{database}-{branch}"
|
210
|
+
|
211
|
+
return _path_cache[cache_key]
|
212
|
+
|
213
|
+
|
214
|
+
def calculate_shard(tenant_name: str) -> str:
|
215
|
+
"""Calculate the shard directory for a tenant using SHA256 hash with caching.
|
216
|
+
|
217
|
+
Args:
|
218
|
+
tenant_name: Name of the tenant
|
219
|
+
|
220
|
+
Returns:
|
221
|
+
Two-character hex string (e.g., "a0", "ff")
|
222
|
+
"""
|
223
|
+
if tenant_name not in _shard_cache:
|
224
|
+
# Check cache size and clear if needed
|
225
|
+
if len(_shard_cache) >= _MAX_CACHE_SIZE:
|
226
|
+
_shard_cache.clear()
|
227
|
+
|
228
|
+
hash_val = hashlib.sha256(tenant_name.encode('utf-8')).hexdigest()
|
229
|
+
_shard_cache[tenant_name] = hash_val[:2]
|
230
|
+
|
231
|
+
return _shard_cache[tenant_name]
|
232
|
+
|
233
|
+
|
234
|
+
def get_tenant_db_path_in_context(context_root: Path, tenant: str) -> Path:
|
235
|
+
"""Get tenant DB path within a context root.
|
236
|
+
|
237
|
+
Uses the tenant-first storage approach where tenants are stored as:
|
238
|
+
{context_root}/{shard}/{tenant}.db
|
239
|
+
|
240
|
+
Args:
|
241
|
+
context_root: Context root directory (from get_context_root)
|
242
|
+
tenant: Tenant name
|
243
|
+
|
244
|
+
Returns:
|
245
|
+
Path to tenant database file within the context
|
246
|
+
"""
|
247
|
+
shard = calculate_shard(tenant)
|
248
|
+
return context_root / shard / f"{tenant}.db"
|
249
|
+
|
250
|
+
|
251
|
+
|
252
|
+
|
253
|
+
def ensure_context_directory(project_root: Path, database: str, branch: str) -> Path:
|
254
|
+
"""Ensure context root directory exists and return it.
|
255
|
+
|
256
|
+
Args:
|
257
|
+
project_root: Project root directory
|
258
|
+
database: Database name
|
259
|
+
branch: Branch name
|
260
|
+
|
261
|
+
Returns:
|
262
|
+
Path to context root directory (created if necessary)
|
263
|
+
"""
|
264
|
+
context_root = get_context_root(project_root, database, branch)
|
265
|
+
context_root.mkdir(parents=True, exist_ok=True)
|
266
|
+
|
267
|
+
return context_root
|
268
|
+
|
269
|
+
|
270
|
+
def ensure_tenant_db_path(project_root: Path, database: str, branch: str, tenant: str) -> Path:
|
271
|
+
"""Ensure tenant database path exists (creates shard directory if needed).
|
272
|
+
|
273
|
+
This is the ONLY place where tenant shard directories should be created,
|
274
|
+
ensuring we have a single source of truth for the directory structure.
|
275
|
+
|
276
|
+
Args:
|
277
|
+
project_root: Project root directory
|
278
|
+
database: Database name
|
279
|
+
branch: Branch name
|
280
|
+
tenant: Tenant name
|
281
|
+
|
282
|
+
Returns:
|
283
|
+
Path to tenant database file (directory created if necessary)
|
284
|
+
|
285
|
+
Raises:
|
286
|
+
InvalidNameError: If any name contains path traversal characters
|
287
|
+
"""
|
288
|
+
# Validation happens inside get_tenant_db_path
|
289
|
+
db_path = get_tenant_db_path(project_root, database, branch, tenant)
|
290
|
+
|
291
|
+
# Ensure the shard directory exists
|
292
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
293
|
+
|
294
|
+
return db_path
|
295
|
+
|
296
|
+
|
297
|
+
def invalidate_cache(database: Optional[str] = None,
|
298
|
+
branch: Optional[str] = None,
|
299
|
+
tenant: Optional[str] = None) -> None:
|
300
|
+
"""Invalidate cache entries (write-through on delete operations).
|
301
|
+
|
302
|
+
This function is called when databases, branches, or tenants are deleted
|
303
|
+
to ensure cached paths don't point to non-existent resources.
|
304
|
+
|
305
|
+
Args:
|
306
|
+
database: Database name to invalidate (invalidates all branches/tenants if provided)
|
307
|
+
branch: Branch name to invalidate (requires database)
|
308
|
+
tenant: Tenant name to invalidate (only invalidates shard cache)
|
309
|
+
"""
|
310
|
+
global _path_cache, _shard_cache
|
311
|
+
|
312
|
+
if database:
|
313
|
+
# Remove all cache entries for this database
|
314
|
+
keys_to_remove = []
|
315
|
+
for key in _path_cache:
|
316
|
+
# key is (project_root, database, branch)
|
317
|
+
if key[1] == database:
|
318
|
+
if branch is None or key[2] == branch:
|
319
|
+
keys_to_remove.append(key)
|
320
|
+
|
321
|
+
for key in keys_to_remove:
|
322
|
+
del _path_cache[key]
|
323
|
+
|
324
|
+
if tenant and tenant in _shard_cache:
|
325
|
+
del _shard_cache[tenant]
|
326
|
+
|
327
|
+
|
328
|
+
def clear_all_caches() -> None:
|
329
|
+
"""Clear all path and shard caches.
|
330
|
+
|
331
|
+
Useful for testing or when major structural changes occur.
|
332
|
+
"""
|
333
|
+
global _path_cache, _shard_cache
|
334
|
+
_path_cache.clear()
|
335
|
+
_shard_cache.clear()
|
336
|
+
|
337
|
+
|
338
|
+
def get_cache_stats() -> Dict[str, int]:
|
339
|
+
"""Get statistics about cache usage.
|
340
|
+
|
341
|
+
Returns:
|
342
|
+
Dictionary with cache statistics
|
343
|
+
"""
|
344
|
+
return {
|
345
|
+
"path_cache_size": len(_path_cache),
|
346
|
+
"shard_cache_size": len(_shard_cache),
|
347
|
+
"max_cache_size": _MAX_CACHE_SIZE
|
348
|
+
}
|
@@ -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
|
@@ -37,6 +37,7 @@ class MetadataDB:
|
|
37
37
|
self.conn.execute("PRAGMA foreign_keys = ON")
|
38
38
|
self.conn.execute("PRAGMA journal_mode = WAL") # Better concurrency
|
39
39
|
|
40
|
+
|
40
41
|
def _create_tables(self):
|
41
42
|
"""Create the metadata tables if they don't exist."""
|
42
43
|
with self.conn:
|
@@ -47,6 +48,9 @@ class MetadataDB:
|
|
47
48
|
name TEXT NOT NULL UNIQUE,
|
48
49
|
description TEXT,
|
49
50
|
materialized BOOLEAN DEFAULT FALSE,
|
51
|
+
maintenance_mode BOOLEAN DEFAULT FALSE,
|
52
|
+
maintenance_reason TEXT,
|
53
|
+
maintenance_started_at TIMESTAMP,
|
50
54
|
metadata JSON,
|
51
55
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
52
56
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
@@ -62,6 +66,9 @@ class MetadataDB:
|
|
62
66
|
parent_branch TEXT,
|
63
67
|
schema_version TEXT,
|
64
68
|
materialized BOOLEAN DEFAULT FALSE,
|
69
|
+
maintenance_mode BOOLEAN DEFAULT FALSE,
|
70
|
+
maintenance_reason TEXT,
|
71
|
+
maintenance_started_at TIMESTAMP,
|
65
72
|
metadata JSON,
|
66
73
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
67
74
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
@@ -116,9 +123,7 @@ class MetadataDB:
|
|
116
123
|
CREATE INDEX IF NOT EXISTS idx_tenants_shard
|
117
124
|
ON tenants(shard)
|
118
125
|
""")
|
119
|
-
|
120
|
-
# Add cdc_enabled field to branches table for plugin use
|
121
|
-
# Note: This field is managed by plugins (like bdhcnic), not core CinchDB
|
126
|
+
|
122
127
|
try:
|
123
128
|
self.conn.execute("""
|
124
129
|
ALTER TABLE branches ADD COLUMN cdc_enabled BOOLEAN DEFAULT FALSE
|
@@ -376,6 +381,94 @@ class MetadataDB:
|
|
376
381
|
|
377
382
|
return len(tenants)
|
378
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
|
+
|
379
472
|
def close(self):
|
380
473
|
"""Close the database connection."""
|
381
474
|
if self.conn:
|