fraiseql-confiture 0.3.4__cp311-cp311-win_amd64.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.
Files changed (119) hide show
  1. confiture/__init__.py +48 -0
  2. confiture/_core.cp311-win_amd64.pyd +0 -0
  3. confiture/cli/__init__.py +0 -0
  4. confiture/cli/dry_run.py +116 -0
  5. confiture/cli/lint_formatter.py +193 -0
  6. confiture/cli/main.py +1656 -0
  7. confiture/config/__init__.py +0 -0
  8. confiture/config/environment.py +263 -0
  9. confiture/core/__init__.py +51 -0
  10. confiture/core/anonymization/__init__.py +0 -0
  11. confiture/core/anonymization/audit.py +485 -0
  12. confiture/core/anonymization/benchmarking.py +372 -0
  13. confiture/core/anonymization/breach_notification.py +652 -0
  14. confiture/core/anonymization/compliance.py +617 -0
  15. confiture/core/anonymization/composer.py +298 -0
  16. confiture/core/anonymization/data_subject_rights.py +669 -0
  17. confiture/core/anonymization/factory.py +319 -0
  18. confiture/core/anonymization/governance.py +737 -0
  19. confiture/core/anonymization/performance.py +1092 -0
  20. confiture/core/anonymization/profile.py +284 -0
  21. confiture/core/anonymization/registry.py +195 -0
  22. confiture/core/anonymization/security/kms_manager.py +547 -0
  23. confiture/core/anonymization/security/lineage.py +888 -0
  24. confiture/core/anonymization/security/token_store.py +686 -0
  25. confiture/core/anonymization/strategies/__init__.py +41 -0
  26. confiture/core/anonymization/strategies/address.py +359 -0
  27. confiture/core/anonymization/strategies/credit_card.py +374 -0
  28. confiture/core/anonymization/strategies/custom.py +161 -0
  29. confiture/core/anonymization/strategies/date.py +218 -0
  30. confiture/core/anonymization/strategies/differential_privacy.py +398 -0
  31. confiture/core/anonymization/strategies/email.py +141 -0
  32. confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
  33. confiture/core/anonymization/strategies/hash.py +150 -0
  34. confiture/core/anonymization/strategies/ip_address.py +235 -0
  35. confiture/core/anonymization/strategies/masking_retention.py +252 -0
  36. confiture/core/anonymization/strategies/name.py +298 -0
  37. confiture/core/anonymization/strategies/phone.py +119 -0
  38. confiture/core/anonymization/strategies/preserve.py +85 -0
  39. confiture/core/anonymization/strategies/redact.py +101 -0
  40. confiture/core/anonymization/strategies/salted_hashing.py +322 -0
  41. confiture/core/anonymization/strategies/text_redaction.py +183 -0
  42. confiture/core/anonymization/strategies/tokenization.py +334 -0
  43. confiture/core/anonymization/strategy.py +241 -0
  44. confiture/core/anonymization/syncer_audit.py +357 -0
  45. confiture/core/blue_green.py +683 -0
  46. confiture/core/builder.py +500 -0
  47. confiture/core/checksum.py +358 -0
  48. confiture/core/connection.py +132 -0
  49. confiture/core/differ.py +522 -0
  50. confiture/core/drift.py +564 -0
  51. confiture/core/dry_run.py +182 -0
  52. confiture/core/health.py +313 -0
  53. confiture/core/hooks/__init__.py +87 -0
  54. confiture/core/hooks/base.py +232 -0
  55. confiture/core/hooks/context.py +146 -0
  56. confiture/core/hooks/execution_strategies.py +57 -0
  57. confiture/core/hooks/observability.py +220 -0
  58. confiture/core/hooks/phases.py +53 -0
  59. confiture/core/hooks/registry.py +295 -0
  60. confiture/core/large_tables.py +775 -0
  61. confiture/core/linting/__init__.py +70 -0
  62. confiture/core/linting/composer.py +192 -0
  63. confiture/core/linting/libraries/__init__.py +17 -0
  64. confiture/core/linting/libraries/gdpr.py +168 -0
  65. confiture/core/linting/libraries/general.py +184 -0
  66. confiture/core/linting/libraries/hipaa.py +144 -0
  67. confiture/core/linting/libraries/pci_dss.py +104 -0
  68. confiture/core/linting/libraries/sox.py +120 -0
  69. confiture/core/linting/schema_linter.py +491 -0
  70. confiture/core/linting/versioning.py +151 -0
  71. confiture/core/locking.py +389 -0
  72. confiture/core/migration_generator.py +298 -0
  73. confiture/core/migrator.py +793 -0
  74. confiture/core/observability/__init__.py +44 -0
  75. confiture/core/observability/audit.py +323 -0
  76. confiture/core/observability/logging.py +187 -0
  77. confiture/core/observability/metrics.py +174 -0
  78. confiture/core/observability/tracing.py +192 -0
  79. confiture/core/pg_version.py +418 -0
  80. confiture/core/pool.py +406 -0
  81. confiture/core/risk/__init__.py +39 -0
  82. confiture/core/risk/predictor.py +188 -0
  83. confiture/core/risk/scoring.py +248 -0
  84. confiture/core/rollback_generator.py +388 -0
  85. confiture/core/schema_analyzer.py +769 -0
  86. confiture/core/schema_to_schema.py +590 -0
  87. confiture/core/security/__init__.py +32 -0
  88. confiture/core/security/logging.py +201 -0
  89. confiture/core/security/validation.py +416 -0
  90. confiture/core/signals.py +371 -0
  91. confiture/core/syncer.py +540 -0
  92. confiture/exceptions.py +192 -0
  93. confiture/integrations/__init__.py +0 -0
  94. confiture/models/__init__.py +0 -0
  95. confiture/models/lint.py +193 -0
  96. confiture/models/migration.py +180 -0
  97. confiture/models/schema.py +203 -0
  98. confiture/scenarios/__init__.py +36 -0
  99. confiture/scenarios/compliance.py +586 -0
  100. confiture/scenarios/ecommerce.py +199 -0
  101. confiture/scenarios/financial.py +253 -0
  102. confiture/scenarios/healthcare.py +315 -0
  103. confiture/scenarios/multi_tenant.py +340 -0
  104. confiture/scenarios/saas.py +295 -0
  105. confiture/testing/FRAMEWORK_API.md +722 -0
  106. confiture/testing/__init__.py +38 -0
  107. confiture/testing/fixtures/__init__.py +11 -0
  108. confiture/testing/fixtures/data_validator.py +229 -0
  109. confiture/testing/fixtures/migration_runner.py +167 -0
  110. confiture/testing/fixtures/schema_snapshotter.py +352 -0
  111. confiture/testing/frameworks/__init__.py +10 -0
  112. confiture/testing/frameworks/mutation.py +587 -0
  113. confiture/testing/frameworks/performance.py +479 -0
  114. confiture/testing/utils/__init__.py +0 -0
  115. fraiseql_confiture-0.3.4.dist-info/METADATA +438 -0
  116. fraiseql_confiture-0.3.4.dist-info/RECORD +119 -0
  117. fraiseql_confiture-0.3.4.dist-info/WHEEL +4 -0
  118. fraiseql_confiture-0.3.4.dist-info/entry_points.txt +2 -0
  119. fraiseql_confiture-0.3.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,683 @@
1
+ """Blue-green migration orchestration.
2
+
3
+ Provides utilities for zero-downtime database migrations using
4
+ blue-green deployment patterns with atomic schema swapping.
5
+ """
6
+
7
+ import datetime
8
+ import logging
9
+ import time
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Any
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class MigrationPhase(Enum):
19
+ """Phases of blue-green migration."""
20
+
21
+ INIT = "init"
22
+ SCHEMA_CREATED = "schema_created"
23
+ DATA_SYNCING = "data_syncing"
24
+ DATA_SYNCED = "data_synced"
25
+ VERIFYING = "verifying"
26
+ TRAFFIC_SWITCHING = "traffic_switching"
27
+ TRAFFIC_SWITCHED = "traffic_switched"
28
+ CLEANUP_PENDING = "cleanup_pending"
29
+ COMPLETE = "complete"
30
+ FAILED = "failed"
31
+ ROLLED_BACK = "rolled_back"
32
+
33
+
34
+ @dataclass
35
+ class BlueGreenConfig:
36
+ """Configuration for blue-green migration.
37
+
38
+ Attributes:
39
+ source_schema: Current production schema (default: public)
40
+ target_schema: New schema to migrate to
41
+ health_check_interval: Seconds between health check retries
42
+ health_check_retries: Number of health check attempts
43
+ sync_timeout: Maximum seconds for data sync
44
+ traffic_switch_delay: Seconds to wait before switching
45
+ skip_cleanup: If True, don't drop old schema
46
+ """
47
+
48
+ source_schema: str = "public"
49
+ target_schema: str = "public_new"
50
+ health_check_interval: float = 5.0
51
+ health_check_retries: int = 3
52
+ sync_timeout: int = 3600
53
+ traffic_switch_delay: float = 10.0
54
+ skip_cleanup: bool = False
55
+
56
+
57
+ @dataclass
58
+ class MigrationState:
59
+ """Current state of blue-green migration.
60
+
61
+ Tracks the migration progress and any errors that occur.
62
+ """
63
+
64
+ phase: MigrationPhase = MigrationPhase.INIT
65
+ source_schema: str = "public"
66
+ target_schema: str = "public_new"
67
+ started_at: str | None = None
68
+ completed_at: str | None = None
69
+ error: str | None = None
70
+ rollback_available: bool = True
71
+ metadata: dict[str, Any] = field(default_factory=dict)
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ """Convert to dictionary."""
75
+ return {
76
+ "phase": self.phase.value,
77
+ "source_schema": self.source_schema,
78
+ "target_schema": self.target_schema,
79
+ "started_at": self.started_at,
80
+ "completed_at": self.completed_at,
81
+ "error": self.error,
82
+ "rollback_available": self.rollback_available,
83
+ "metadata": self.metadata,
84
+ }
85
+
86
+
87
+ @dataclass
88
+ class HealthCheckResult:
89
+ """Result of a health check."""
90
+
91
+ name: str
92
+ passed: bool
93
+ message: str | None = None
94
+ duration_ms: int = 0
95
+
96
+
97
+ class BlueGreenOrchestrator:
98
+ """Orchestrate blue-green database migrations.
99
+
100
+ Provides a structured approach to zero-downtime migrations:
101
+ 1. Create target schema
102
+ 2. Sync data (via FDW or other mechanism)
103
+ 3. Verify data integrity
104
+ 4. Run health checks
105
+ 5. Atomic schema swap
106
+ 6. Cleanup (optional)
107
+
108
+ Example:
109
+ >>> config = BlueGreenConfig(target_schema="public_v2")
110
+ >>> orchestrator = BlueGreenOrchestrator(conn, config)
111
+ >>> orchestrator.add_health_check("api_health", check_api)
112
+ >>> state = orchestrator.execute()
113
+ >>> print(f"Migration: {state.phase.value}")
114
+ """
115
+
116
+ def __init__(self, connection: Any, config: BlueGreenConfig | None = None):
117
+ """Initialize orchestrator.
118
+
119
+ Args:
120
+ connection: Database connection
121
+ config: Migration configuration
122
+ """
123
+ self.connection = connection
124
+ self.config = config or BlueGreenConfig()
125
+ self.state = MigrationState(
126
+ source_schema=self.config.source_schema,
127
+ target_schema=self.config.target_schema,
128
+ )
129
+ self._health_checks: list[tuple[str, Callable[[], bool]]] = []
130
+ self._on_phase_change: list[Callable[[MigrationPhase, MigrationPhase], None]] = []
131
+ self._data_sync_fn: Callable[[], None] | None = None
132
+
133
+ @property
134
+ def current_phase(self) -> MigrationPhase:
135
+ """Get current migration phase."""
136
+ return self.state.phase
137
+
138
+ def add_health_check(self, name: str, check: Callable[[], bool]) -> None:
139
+ """Add a health check function.
140
+
141
+ Health checks are run before traffic switch. All must pass.
142
+
143
+ Args:
144
+ name: Name for this health check
145
+ check: Function returning True if healthy
146
+
147
+ Example:
148
+ >>> def check_api():
149
+ ... response = requests.get("http://localhost/health")
150
+ ... return response.status_code == 200
151
+ >>> orchestrator.add_health_check("api", check_api)
152
+ """
153
+ self._health_checks.append((name, check))
154
+
155
+ def on_phase_change(self, callback: Callable[[MigrationPhase, MigrationPhase], None]) -> None:
156
+ """Register callback for phase changes.
157
+
158
+ Args:
159
+ callback: Function called with (old_phase, new_phase)
160
+
161
+ Example:
162
+ >>> def log_phase(old, new):
163
+ ... print(f"Phase changed: {old.value} -> {new.value}")
164
+ >>> orchestrator.on_phase_change(log_phase)
165
+ """
166
+ self._on_phase_change.append(callback)
167
+
168
+ def set_data_sync_function(self, fn: Callable[[], None]) -> None:
169
+ """Set custom data sync function.
170
+
171
+ By default, sync is a placeholder. Set this to integrate
172
+ with your data sync mechanism (FDW, pg_dump, etc.).
173
+
174
+ Args:
175
+ fn: Function to sync data from source to target
176
+ """
177
+ self._data_sync_fn = fn
178
+
179
+ def _set_phase(self, phase: MigrationPhase) -> None:
180
+ """Update phase and notify callbacks."""
181
+ old_phase = self.state.phase
182
+ self.state.phase = phase
183
+ logger.info(f"Phase: {old_phase.value} -> {phase.value}")
184
+
185
+ for callback in self._on_phase_change:
186
+ try:
187
+ callback(old_phase, phase)
188
+ except Exception as e:
189
+ logger.warning(f"Phase callback failed: {e}")
190
+
191
+ def execute(self) -> MigrationState:
192
+ """Execute full blue-green migration.
193
+
194
+ Returns:
195
+ MigrationState with final status
196
+
197
+ Raises:
198
+ RuntimeError: If migration fails and cannot be rolled back
199
+ """
200
+ self.state.started_at = datetime.datetime.now(datetime.UTC).isoformat()
201
+
202
+ try:
203
+ self._create_target_schema()
204
+ self._sync_data()
205
+ self._verify_sync()
206
+
207
+ health_results = self._run_health_checks()
208
+ if not all(r.passed for r in health_results):
209
+ failed = [r.name for r in health_results if not r.passed]
210
+ raise RuntimeError(f"Health checks failed: {', '.join(failed)}")
211
+
212
+ self._switch_traffic()
213
+
214
+ if not self.config.skip_cleanup:
215
+ self._cleanup()
216
+
217
+ self.state.completed_at = datetime.datetime.now(datetime.UTC).isoformat()
218
+ self._set_phase(MigrationPhase.COMPLETE)
219
+
220
+ except Exception as e:
221
+ self.state.error = str(e)
222
+ self._set_phase(MigrationPhase.FAILED)
223
+ logger.error(f"Migration failed: {e}")
224
+ self._attempt_rollback()
225
+ raise
226
+
227
+ return self.state
228
+
229
+ def _create_target_schema(self) -> None:
230
+ """Create target schema for new version."""
231
+ with self.connection.cursor() as cur:
232
+ cur.execute(f"CREATE SCHEMA IF NOT EXISTS {self.config.target_schema}")
233
+ self.connection.commit()
234
+ self._set_phase(MigrationPhase.SCHEMA_CREATED)
235
+ logger.info(f"Created schema: {self.config.target_schema}")
236
+
237
+ def _sync_data(self) -> None:
238
+ """Sync data from source to target schema."""
239
+ self._set_phase(MigrationPhase.DATA_SYNCING)
240
+
241
+ if self._data_sync_fn:
242
+ self._data_sync_fn()
243
+ else:
244
+ logger.info(
245
+ "No data sync function set. "
246
+ "Use set_data_sync_function() to integrate with FDW or other sync."
247
+ )
248
+
249
+ self._set_phase(MigrationPhase.DATA_SYNCED)
250
+
251
+ def _verify_sync(self) -> None:
252
+ """Verify data sync is complete."""
253
+ self._set_phase(MigrationPhase.VERIFYING)
254
+
255
+ discrepancies = self._compare_schemas()
256
+ if discrepancies:
257
+ for table, diff in discrepancies.items():
258
+ logger.warning(
259
+ f"Row count mismatch in {table}: "
260
+ f"source={diff['source']}, target={diff['target']}"
261
+ )
262
+ self.state.metadata["sync_discrepancies"] = discrepancies
263
+
264
+ logger.info("Data sync verification complete")
265
+
266
+ def _compare_schemas(self) -> dict[str, dict[str, int]]:
267
+ """Compare row counts between schemas.
268
+
269
+ Returns:
270
+ Dictionary of tables with mismatched counts
271
+ """
272
+ discrepancies: dict[str, dict[str, int]] = {}
273
+
274
+ with self.connection.cursor() as cur:
275
+ cur.execute(
276
+ """
277
+ SELECT schemaname, relname, n_live_tup
278
+ FROM pg_stat_user_tables
279
+ WHERE schemaname IN (%s, %s)
280
+ ORDER BY relname, schemaname
281
+ """,
282
+ (self.config.source_schema, self.config.target_schema),
283
+ )
284
+
285
+ stats: dict[str, dict[str, int]] = {}
286
+ for row in cur.fetchall():
287
+ schema, table, count = row
288
+ if table not in stats:
289
+ stats[table] = {}
290
+ stats[table][schema] = count or 0
291
+
292
+ for table, counts in stats.items():
293
+ source_count = counts.get(self.config.source_schema, 0)
294
+ target_count = counts.get(self.config.target_schema, 0)
295
+
296
+ if source_count != target_count:
297
+ discrepancies[table] = {
298
+ "source": source_count,
299
+ "target": target_count,
300
+ }
301
+
302
+ return discrepancies
303
+
304
+ def _run_health_checks(self) -> list[HealthCheckResult]:
305
+ """Run all health checks with retries.
306
+
307
+ Returns:
308
+ List of health check results
309
+ """
310
+ if not self._health_checks:
311
+ logger.info("No health checks configured, proceeding")
312
+ return []
313
+
314
+ results: list[HealthCheckResult] = []
315
+
316
+ for attempt in range(self.config.health_check_retries):
317
+ results = []
318
+ all_passed = True
319
+
320
+ for name, check in self._health_checks:
321
+ start_time = time.perf_counter()
322
+ try:
323
+ passed = check()
324
+ duration_ms = int((time.perf_counter() - start_time) * 1000)
325
+ results.append(
326
+ HealthCheckResult(
327
+ name=name,
328
+ passed=passed,
329
+ duration_ms=duration_ms,
330
+ )
331
+ )
332
+ if not passed:
333
+ logger.warning(f"Health check '{name}' failed")
334
+ all_passed = False
335
+ except Exception as e:
336
+ duration_ms = int((time.perf_counter() - start_time) * 1000)
337
+ results.append(
338
+ HealthCheckResult(
339
+ name=name,
340
+ passed=False,
341
+ message=str(e),
342
+ duration_ms=duration_ms,
343
+ )
344
+ )
345
+ logger.warning(f"Health check '{name}' error: {e}")
346
+ all_passed = False
347
+
348
+ if all_passed:
349
+ logger.info(f"All {len(self._health_checks)} health checks passed")
350
+ break
351
+
352
+ if attempt < self.config.health_check_retries - 1:
353
+ logger.info(
354
+ f"Retrying health checks in {self.config.health_check_interval}s "
355
+ f"(attempt {attempt + 2}/{self.config.health_check_retries})"
356
+ )
357
+ time.sleep(self.config.health_check_interval)
358
+
359
+ self.state.metadata["health_check_results"] = [r.__dict__ for r in results]
360
+ return results
361
+
362
+ def _switch_traffic(self) -> None:
363
+ """Switch traffic by renaming schemas atomically."""
364
+ self._set_phase(MigrationPhase.TRAFFIC_SWITCHING)
365
+
366
+ if self.config.traffic_switch_delay > 0:
367
+ logger.info(f"Waiting {self.config.traffic_switch_delay}s before traffic switch...")
368
+ time.sleep(self.config.traffic_switch_delay)
369
+
370
+ backup_schema = f"{self.config.source_schema}_backup_{int(time.time())}"
371
+
372
+ with self.connection.cursor() as cur:
373
+ # Atomic schema swap using a single transaction
374
+ cur.execute(
375
+ f"""
376
+ ALTER SCHEMA {self.config.source_schema} RENAME TO {backup_schema};
377
+ ALTER SCHEMA {self.config.target_schema}
378
+ RENAME TO {self.config.source_schema};
379
+ """
380
+ )
381
+ self.connection.commit()
382
+
383
+ self.state.metadata["backup_schema"] = backup_schema
384
+ self.state.rollback_available = True
385
+ self._set_phase(MigrationPhase.TRAFFIC_SWITCHED)
386
+ logger.info(f"Traffic switched. Old schema backed up as: {backup_schema}")
387
+
388
+ def _cleanup(self) -> None:
389
+ """Mark cleanup as pending (actual cleanup is manual)."""
390
+ self._set_phase(MigrationPhase.CLEANUP_PENDING)
391
+
392
+ backup_schema = self.state.metadata.get("backup_schema")
393
+ if backup_schema:
394
+ logger.info(f"Old schema preserved as: {backup_schema}")
395
+ logger.info(
396
+ f"To remove: DROP SCHEMA {backup_schema} CASCADE; "
397
+ "or run 'confiture migrate cleanup'"
398
+ )
399
+
400
+ def _attempt_rollback(self) -> None:
401
+ """Attempt to rollback on failure."""
402
+ if not self.state.rollback_available:
403
+ logger.warning("Rollback not available from current state")
404
+ return
405
+
406
+ phase = self.state.phase
407
+
408
+ if phase in (
409
+ MigrationPhase.INIT,
410
+ MigrationPhase.SCHEMA_CREATED,
411
+ MigrationPhase.DATA_SYNCING,
412
+ MigrationPhase.DATA_SYNCED,
413
+ MigrationPhase.VERIFYING,
414
+ MigrationPhase.FAILED,
415
+ ):
416
+ # Safe to rollback - just drop target schema
417
+ self._rollback_drop_target()
418
+
419
+ elif phase == MigrationPhase.TRAFFIC_SWITCHED:
420
+ # Need to swap schemas back
421
+ self._rollback_swap_back()
422
+
423
+ else:
424
+ logger.warning(f"Cannot auto-rollback from phase: {phase.value}")
425
+
426
+ def _rollback_drop_target(self) -> None:
427
+ """Rollback by dropping target schema."""
428
+ try:
429
+ with self.connection.cursor() as cur:
430
+ cur.execute(f"DROP SCHEMA IF EXISTS {self.config.target_schema} CASCADE")
431
+ self.connection.commit()
432
+ self._set_phase(MigrationPhase.ROLLED_BACK)
433
+ logger.info(f"Rolled back: dropped {self.config.target_schema}")
434
+ except Exception as e:
435
+ logger.error(f"Rollback failed: {e}")
436
+ self.state.rollback_available = False
437
+
438
+ def _rollback_swap_back(self) -> None:
439
+ """Rollback by swapping schemas back."""
440
+ backup_schema = self.state.metadata.get("backup_schema")
441
+ if not backup_schema:
442
+ logger.error("Cannot rollback: backup schema not found")
443
+ return
444
+
445
+ try:
446
+ temp_schema = f"_rollback_temp_{int(time.time())}"
447
+ with self.connection.cursor() as cur:
448
+ # Swap back: current -> temp, backup -> current
449
+ cur.execute(
450
+ f"""
451
+ ALTER SCHEMA {self.config.source_schema} RENAME TO {temp_schema};
452
+ ALTER SCHEMA {backup_schema} RENAME TO {self.config.source_schema};
453
+ DROP SCHEMA {temp_schema} CASCADE;
454
+ """
455
+ )
456
+ self.connection.commit()
457
+ self._set_phase(MigrationPhase.ROLLED_BACK)
458
+ logger.info("Rolled back: restored original schema")
459
+ except Exception as e:
460
+ logger.error(f"Rollback failed: {e}")
461
+ self.state.rollback_available = False
462
+
463
+ def rollback(self) -> bool:
464
+ """Manually trigger rollback.
465
+
466
+ Returns:
467
+ True if rollback succeeded
468
+ """
469
+ if not self.state.rollback_available:
470
+ logger.warning("Rollback not available")
471
+ return False
472
+
473
+ self._attempt_rollback()
474
+ return self.state.phase == MigrationPhase.ROLLED_BACK
475
+
476
+ def cleanup_backup(self) -> bool:
477
+ """Remove the backup schema.
478
+
479
+ Returns:
480
+ True if cleanup succeeded
481
+ """
482
+ backup_schema = self.state.metadata.get("backup_schema")
483
+ if not backup_schema:
484
+ logger.warning("No backup schema to clean up")
485
+ return False
486
+
487
+ try:
488
+ with self.connection.cursor() as cur:
489
+ cur.execute(f"DROP SCHEMA IF EXISTS {backup_schema} CASCADE")
490
+ self.connection.commit()
491
+ self.state.rollback_available = False
492
+ logger.info(f"Cleaned up backup schema: {backup_schema}")
493
+ return True
494
+ except Exception as e:
495
+ logger.error(f"Cleanup failed: {e}")
496
+ return False
497
+
498
+
499
+ class TrafficController:
500
+ """Control application traffic during migration.
501
+
502
+ Provides utilities for managing read-only mode and draining
503
+ connections during blue-green migrations.
504
+
505
+ Example:
506
+ >>> controller = TrafficController()
507
+ >>> controller.set_read_only(conn, True)
508
+ >>> # ... perform migration ...
509
+ >>> controller.set_read_only(conn, False)
510
+ """
511
+
512
+ def __init__(
513
+ self,
514
+ redis_client: Any | None = None,
515
+ feature_flag_client: Any | None = None,
516
+ ):
517
+ """Initialize traffic controller.
518
+
519
+ Args:
520
+ redis_client: Optional Redis client for state storage
521
+ feature_flag_client: Optional feature flag client
522
+ """
523
+ self.redis = redis_client
524
+ self.feature_flags = feature_flag_client
525
+ self._read_only = False
526
+
527
+ def set_read_only(self, connection: Any, enabled: bool) -> None:
528
+ """Enable/disable read-only mode.
529
+
530
+ When enabled, sets the database connection to read-only
531
+ and optionally updates Redis/feature flags.
532
+
533
+ Args:
534
+ connection: Database connection
535
+ enabled: True to enable read-only mode
536
+ """
537
+ self._read_only = enabled
538
+
539
+ # Set database connection to read-only transaction
540
+ if enabled:
541
+ with connection.cursor() as cur:
542
+ cur.execute("SET default_transaction_read_only = ON")
543
+ connection.commit()
544
+ logger.info("Database connection set to read-only")
545
+ else:
546
+ with connection.cursor() as cur:
547
+ cur.execute("SET default_transaction_read_only = OFF")
548
+ connection.commit()
549
+ logger.info("Database connection set to read-write")
550
+
551
+ # Update external state stores
552
+ if self.redis:
553
+ if enabled:
554
+ self.redis.set("confiture:read_only", "1")
555
+ else:
556
+ self.redis.delete("confiture:read_only")
557
+ logger.info(f"Redis read_only flag: {enabled}")
558
+
559
+ if self.feature_flags:
560
+ self.feature_flags.set("database_read_only", enabled)
561
+ logger.info(f"Feature flag database_read_only: {enabled}")
562
+
563
+ def is_read_only(self) -> bool:
564
+ """Check if read-only mode is enabled.
565
+
566
+ Returns:
567
+ True if read-only mode is active
568
+ """
569
+ if self.redis:
570
+ value = self.redis.get("confiture:read_only")
571
+ return value == "1" or value == b"1"
572
+ return self._read_only
573
+
574
+ def get_active_connections(self, connection: Any) -> list[dict[str, Any]]:
575
+ """Get list of active database connections.
576
+
577
+ Args:
578
+ connection: Database connection
579
+
580
+ Returns:
581
+ List of connection info dictionaries
582
+ """
583
+ with connection.cursor() as cur:
584
+ cur.execute(
585
+ """
586
+ SELECT
587
+ pid,
588
+ usename,
589
+ application_name,
590
+ client_addr,
591
+ state,
592
+ query_start,
593
+ wait_event_type,
594
+ wait_event
595
+ FROM pg_stat_activity
596
+ WHERE datname = current_database()
597
+ AND pid != pg_backend_pid()
598
+ ORDER BY query_start DESC
599
+ """
600
+ )
601
+
602
+ columns = [desc[0] for desc in cur.description]
603
+ return [dict(zip(columns, row, strict=True)) for row in cur.fetchall()]
604
+
605
+ def drain_connections(
606
+ self,
607
+ connection: Any,
608
+ timeout: int = 30,
609
+ check_interval: float = 1.0,
610
+ exclude_apps: list[str] | None = None,
611
+ ) -> bool:
612
+ """Wait for active connections to drain.
613
+
614
+ Args:
615
+ connection: Database connection
616
+ timeout: Maximum seconds to wait
617
+ check_interval: Seconds between checks
618
+ exclude_apps: Application names to exclude from check
619
+
620
+ Returns:
621
+ True if all connections drained within timeout
622
+ """
623
+ exclude_apps = exclude_apps or ["confiture"]
624
+ start_time = time.time()
625
+
626
+ logger.info(f"Draining connections (timeout={timeout}s)...")
627
+
628
+ while time.time() - start_time < timeout:
629
+ active = self.get_active_connections(connection)
630
+
631
+ # Filter out excluded applications
632
+ active = [
633
+ c
634
+ for c in active
635
+ if c.get("application_name") not in exclude_apps and c.get("state") != "idle"
636
+ ]
637
+
638
+ if not active:
639
+ logger.info("All connections drained")
640
+ return True
641
+
642
+ logger.info(f"Waiting for {len(active)} active connections...")
643
+ time.sleep(check_interval)
644
+
645
+ logger.warning("Timeout waiting for connections to drain")
646
+ return False
647
+
648
+ def terminate_connections(
649
+ self,
650
+ connection: Any,
651
+ exclude_apps: list[str] | None = None,
652
+ ) -> int:
653
+ """Terminate active connections (use with caution).
654
+
655
+ Args:
656
+ connection: Database connection
657
+ exclude_apps: Application names to exclude
658
+
659
+ Returns:
660
+ Number of connections terminated
661
+ """
662
+ exclude_apps = exclude_apps or ["confiture"]
663
+ terminated = 0
664
+
665
+ active = self.get_active_connections(connection)
666
+
667
+ with connection.cursor() as cur:
668
+ for conn_info in active:
669
+ app_name = conn_info.get("application_name", "")
670
+ if app_name in exclude_apps:
671
+ continue
672
+
673
+ pid = conn_info["pid"]
674
+ try:
675
+ cur.execute("SELECT pg_terminate_backend(%s)", (pid,))
676
+ terminated += 1
677
+ logger.info(f"Terminated connection: pid={pid}, app={app_name}")
678
+ except Exception as e:
679
+ logger.warning(f"Failed to terminate pid={pid}: {e}")
680
+
681
+ connection.commit()
682
+ logger.info(f"Terminated {terminated} connections")
683
+ return terminated