truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.0__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.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -22
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
  164. truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
  166. truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -22,6 +22,7 @@ import logging
22
22
  from abc import ABC, abstractmethod
23
23
  from dataclasses import dataclass, field
24
24
  from datetime import datetime, timedelta
25
+ from enum import Enum
25
26
  from typing import Any
26
27
 
27
28
  from sqlalchemy import delete, func, select, text
@@ -38,6 +39,46 @@ from truthound_dashboard.db.models import (
38
39
  logger = logging.getLogger(__name__)
39
40
 
40
41
 
42
+ class RetentionPolicyType(str, Enum):
43
+ """Types of retention policies."""
44
+
45
+ TIME = "time" # Time-based retention (days)
46
+ COUNT = "count" # Count-based retention (keep N records)
47
+ SIZE = "size" # Size-based retention (keep under N bytes)
48
+ STATUS = "status" # Status-based retention (keep passed/failed)
49
+ TAG = "tag" # Tag-based retention (keep/remove by tag)
50
+ COMPOSITE = "composite" # Combination of multiple policies
51
+
52
+
53
+ @dataclass
54
+ class RetentionPolicy:
55
+ """Individual retention policy definition.
56
+
57
+ Attributes:
58
+ policy_type: Type of retention policy.
59
+ value: Policy value (days, count, bytes, status, or tag).
60
+ target: What this policy applies to (validations, profiles, etc).
61
+ priority: Policy priority (higher = applied first).
62
+ enabled: Whether this policy is active.
63
+ """
64
+
65
+ policy_type: RetentionPolicyType
66
+ value: Any
67
+ target: str = "validations"
68
+ priority: int = 0
69
+ enabled: bool = True
70
+
71
+ def to_dict(self) -> dict[str, Any]:
72
+ """Convert to dictionary."""
73
+ return {
74
+ "policy_type": self.policy_type.value if isinstance(self.policy_type, Enum) else self.policy_type,
75
+ "value": self.value,
76
+ "target": self.target,
77
+ "priority": self.priority,
78
+ "enabled": self.enabled,
79
+ }
80
+
81
+
41
82
  @dataclass
42
83
  class CleanupResult:
43
84
  """Result of a cleanup operation.
@@ -71,11 +112,26 @@ class CleanupResult:
71
112
  class MaintenanceConfig:
72
113
  """Configuration for maintenance tasks.
73
114
 
115
+ Supports 6 retention policy types:
116
+ - Time: Keep records for N days
117
+ - Count: Keep N most recent records
118
+ - Size: Keep total storage under N bytes
119
+ - Status: Different retention for passed/failed validations
120
+ - Tag: Protect or delete records by tag
121
+ - Composite: Combine multiple policies
122
+
74
123
  Attributes:
75
- validation_retention_days: Days to keep validation records.
76
- profile_keep_per_source: Number of profiles to keep per source.
77
- notification_log_retention_days: Days to keep notification logs.
124
+ validation_retention_days: Days to keep validation records (Time policy).
125
+ profile_keep_per_source: Number of profiles to keep per source (Count policy).
126
+ notification_log_retention_days: Days to keep notification logs (Time policy).
78
127
  run_vacuum: Whether to run VACUUM after cleanup.
128
+ enabled: Whether automatic maintenance is enabled.
129
+ max_storage_mb: Maximum storage in MB for validations (Size policy).
130
+ keep_failed_validations: Whether to keep failed validations longer (Status policy).
131
+ failed_retention_days: Days to keep failed validations (Status policy).
132
+ protected_tags: Tags to never delete (Tag policy).
133
+ delete_tags: Tags to delete after standard retention (Tag policy).
134
+ policies: List of custom retention policies (Composite).
79
135
  """
80
136
 
81
137
  validation_retention_days: int = 90
@@ -83,6 +139,106 @@ class MaintenanceConfig:
83
139
  notification_log_retention_days: int = 30
84
140
  run_vacuum: bool = True
85
141
  enabled: bool = True
142
+ # Size-based retention
143
+ max_storage_mb: int | None = None # None = no limit
144
+ # Status-based retention
145
+ keep_failed_validations: bool = True # Keep failed longer for debugging
146
+ failed_retention_days: int = 180 # Days to keep failed validations
147
+ # Tag-based retention
148
+ protected_tags: list[str] = field(default_factory=list) # Tags to never delete
149
+ delete_tags: list[str] = field(default_factory=list) # Tags to delete after retention
150
+ # Custom policies (Composite)
151
+ policies: list[RetentionPolicy] = field(default_factory=list)
152
+
153
+ def get_active_policies(self) -> list[RetentionPolicy]:
154
+ """Get all active retention policies sorted by priority.
155
+
156
+ Returns:
157
+ List of RetentionPolicy sorted by priority (highest first).
158
+ """
159
+ active: list[RetentionPolicy] = []
160
+
161
+ # Time policy for validations
162
+ active.append(RetentionPolicy(
163
+ policy_type=RetentionPolicyType.TIME,
164
+ value=self.validation_retention_days,
165
+ target="validations",
166
+ priority=10,
167
+ ))
168
+
169
+ # Count policy for profiles
170
+ active.append(RetentionPolicy(
171
+ policy_type=RetentionPolicyType.COUNT,
172
+ value=self.profile_keep_per_source,
173
+ target="profiles",
174
+ priority=10,
175
+ ))
176
+
177
+ # Time policy for notification logs
178
+ active.append(RetentionPolicy(
179
+ policy_type=RetentionPolicyType.TIME,
180
+ value=self.notification_log_retention_days,
181
+ target="notification_logs",
182
+ priority=10,
183
+ ))
184
+
185
+ # Size policy if configured
186
+ if self.max_storage_mb is not None:
187
+ active.append(RetentionPolicy(
188
+ policy_type=RetentionPolicyType.SIZE,
189
+ value=self.max_storage_mb * 1024 * 1024, # Convert to bytes
190
+ target="validations",
191
+ priority=20,
192
+ ))
193
+
194
+ # Status policy if keeping failed validations longer
195
+ if self.keep_failed_validations:
196
+ active.append(RetentionPolicy(
197
+ policy_type=RetentionPolicyType.STATUS,
198
+ value={"status": "failed", "retention_days": self.failed_retention_days},
199
+ target="validations",
200
+ priority=15,
201
+ ))
202
+
203
+ # Tag policy for protected tags
204
+ if self.protected_tags:
205
+ active.append(RetentionPolicy(
206
+ policy_type=RetentionPolicyType.TAG,
207
+ value={"protect": self.protected_tags},
208
+ target="validations",
209
+ priority=100, # Highest priority - never delete
210
+ ))
211
+
212
+ # Tag policy for delete tags
213
+ if self.delete_tags:
214
+ active.append(RetentionPolicy(
215
+ policy_type=RetentionPolicyType.TAG,
216
+ value={"delete": self.delete_tags},
217
+ target="validations",
218
+ priority=5, # Lower priority
219
+ ))
220
+
221
+ # Add custom policies
222
+ active.extend([p for p in self.policies if p.enabled])
223
+
224
+ # Sort by priority (descending)
225
+ return sorted(active, key=lambda p: p.priority, reverse=True)
226
+
227
+ def to_dict(self) -> dict[str, Any]:
228
+ """Convert to dictionary for API responses."""
229
+ return {
230
+ "validation_retention_days": self.validation_retention_days,
231
+ "profile_keep_per_source": self.profile_keep_per_source,
232
+ "notification_log_retention_days": self.notification_log_retention_days,
233
+ "run_vacuum": self.run_vacuum,
234
+ "enabled": self.enabled,
235
+ "max_storage_mb": self.max_storage_mb,
236
+ "keep_failed_validations": self.keep_failed_validations,
237
+ "failed_retention_days": self.failed_retention_days,
238
+ "protected_tags": self.protected_tags,
239
+ "delete_tags": self.delete_tags,
240
+ "active_policies": [p.to_dict() for p in self.get_active_policies()],
241
+ }
86
242
 
87
243
 
88
244
  class CleanupStrategy(ABC):
@@ -300,6 +456,272 @@ class NotificationLogCleanupStrategy(CleanupStrategy):
300
456
  )
301
457
 
302
458
 
459
+ class SizeBasedCleanupStrategy(CleanupStrategy):
460
+ """Cleanup strategy based on total storage size.
461
+
462
+ Removes oldest validations when total storage exceeds limit.
463
+ """
464
+
465
+ @property
466
+ def name(self) -> str:
467
+ return "size_based_cleanup"
468
+
469
+ async def execute(self, config: MaintenanceConfig) -> CleanupResult:
470
+ """Remove validations if total storage exceeds limit."""
471
+ start_time = datetime.utcnow()
472
+ total_deleted = 0
473
+
474
+ if config.max_storage_mb is None:
475
+ return CleanupResult(
476
+ task_name=self.name,
477
+ records_deleted=0,
478
+ duration_ms=0,
479
+ success=True,
480
+ )
481
+
482
+ max_bytes = config.max_storage_mb * 1024 * 1024
483
+
484
+ try:
485
+ async with get_session() as session:
486
+ # Estimate current storage (based on result_json size)
487
+ # In production, you might track actual file sizes
488
+ size_result = await session.execute(
489
+ select(func.sum(func.length(Validation.result_json)))
490
+ )
491
+ current_size = size_result.scalar() or 0
492
+
493
+ if current_size <= max_bytes:
494
+ duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
495
+ return CleanupResult(
496
+ task_name=self.name,
497
+ records_deleted=0,
498
+ duration_ms=duration,
499
+ success=True,
500
+ )
501
+
502
+ # Calculate how much to delete (aim for 80% of max)
503
+ target_size = int(max_bytes * 0.8)
504
+ bytes_to_free = current_size - target_size
505
+
506
+ # Get validations ordered by created_at, delete oldest first
507
+ validations_result = await session.execute(
508
+ select(Validation.id, func.length(Validation.result_json))
509
+ .order_by(Validation.created_at.asc())
510
+ )
511
+
512
+ freed_bytes = 0
513
+ ids_to_delete = []
514
+
515
+ for val_id, size in validations_result:
516
+ if freed_bytes >= bytes_to_free:
517
+ break
518
+ ids_to_delete.append(val_id)
519
+ freed_bytes += size or 0
520
+
521
+ if ids_to_delete:
522
+ await session.execute(
523
+ delete(Validation).where(Validation.id.in_(ids_to_delete))
524
+ )
525
+ total_deleted = len(ids_to_delete)
526
+
527
+ duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
528
+
529
+ logger.info(
530
+ f"Size-based cleanup: deleted {total_deleted} records, "
531
+ f"freed ~{freed_bytes / 1024 / 1024:.2f} MB"
532
+ )
533
+
534
+ return CleanupResult(
535
+ task_name=self.name,
536
+ records_deleted=total_deleted,
537
+ duration_ms=duration,
538
+ success=True,
539
+ )
540
+
541
+ except Exception as e:
542
+ duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
543
+ logger.error(f"Size-based cleanup failed: {e}")
544
+ return CleanupResult(
545
+ task_name=self.name,
546
+ duration_ms=duration,
547
+ success=False,
548
+ error=str(e),
549
+ )
550
+
551
+
552
+ class StatusBasedCleanupStrategy(CleanupStrategy):
553
+ """Cleanup strategy based on validation status.
554
+
555
+ Applies different retention rules for passed vs failed validations.
556
+ Failed validations are kept longer for debugging purposes.
557
+ """
558
+
559
+ @property
560
+ def name(self) -> str:
561
+ return "status_based_cleanup"
562
+
563
+ async def execute(self, config: MaintenanceConfig) -> CleanupResult:
564
+ """Remove validations based on status-specific retention."""
565
+ start_time = datetime.utcnow()
566
+ total_deleted = 0
567
+
568
+ if not config.keep_failed_validations:
569
+ return CleanupResult(
570
+ task_name=self.name,
571
+ records_deleted=0,
572
+ duration_ms=0,
573
+ success=True,
574
+ )
575
+
576
+ try:
577
+ # Standard cutoff for passed validations
578
+ passed_cutoff = datetime.utcnow() - timedelta(
579
+ days=config.validation_retention_days
580
+ )
581
+ # Extended cutoff for failed validations
582
+ failed_cutoff = datetime.utcnow() - timedelta(
583
+ days=config.failed_retention_days
584
+ )
585
+
586
+ async with get_session() as session:
587
+ # Delete old passed validations (use standard retention)
588
+ passed_count_result = await session.execute(
589
+ select(func.count(Validation.id)).where(
590
+ Validation.passed == True, # noqa: E712
591
+ Validation.created_at < passed_cutoff,
592
+ )
593
+ )
594
+ passed_count = passed_count_result.scalar() or 0
595
+
596
+ if passed_count > 0:
597
+ await session.execute(
598
+ delete(Validation).where(
599
+ Validation.passed == True, # noqa: E712
600
+ Validation.created_at < passed_cutoff,
601
+ )
602
+ )
603
+ total_deleted += passed_count
604
+
605
+ # Delete old failed validations (use extended retention)
606
+ failed_count_result = await session.execute(
607
+ select(func.count(Validation.id)).where(
608
+ Validation.passed == False, # noqa: E712
609
+ Validation.created_at < failed_cutoff,
610
+ )
611
+ )
612
+ failed_count = failed_count_result.scalar() or 0
613
+
614
+ if failed_count > 0:
615
+ await session.execute(
616
+ delete(Validation).where(
617
+ Validation.passed == False, # noqa: E712
618
+ Validation.created_at < failed_cutoff,
619
+ )
620
+ )
621
+ total_deleted += failed_count
622
+
623
+ duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
624
+
625
+ logger.info(
626
+ f"Status-based cleanup: deleted {passed_count} passed, "
627
+ f"{failed_count} failed validations"
628
+ )
629
+
630
+ return CleanupResult(
631
+ task_name=self.name,
632
+ records_deleted=total_deleted,
633
+ duration_ms=duration,
634
+ success=True,
635
+ )
636
+
637
+ except Exception as e:
638
+ duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
639
+ logger.error(f"Status-based cleanup failed: {e}")
640
+ return CleanupResult(
641
+ task_name=self.name,
642
+ duration_ms=duration,
643
+ success=False,
644
+ error=str(e),
645
+ )
646
+
647
+
648
+ class TagBasedCleanupStrategy(CleanupStrategy):
649
+ """Cleanup strategy based on validation tags.
650
+
651
+ Protects tagged validations or deletes them based on configuration.
652
+ Tags can be stored in validation metadata or a separate tags table.
653
+ """
654
+
655
+ @property
656
+ def name(self) -> str:
657
+ return "tag_based_cleanup"
658
+
659
+ async def execute(self, config: MaintenanceConfig) -> CleanupResult:
660
+ """Handle tag-based retention rules."""
661
+ start_time = datetime.utcnow()
662
+ total_deleted = 0
663
+
664
+ if not config.protected_tags and not config.delete_tags:
665
+ return CleanupResult(
666
+ task_name=self.name,
667
+ records_deleted=0,
668
+ duration_ms=0,
669
+ success=True,
670
+ )
671
+
672
+ try:
673
+ async with get_session() as session:
674
+ # Handle delete_tags: remove validations with these tags
675
+ # that are past standard retention
676
+ if config.delete_tags:
677
+ cutoff = datetime.utcnow() - timedelta(
678
+ days=config.validation_retention_days
679
+ )
680
+
681
+ for tag in config.delete_tags:
682
+ # Tags stored in result_json metadata
683
+ # This is a simplified approach; production might use a tags table
684
+ count_result = await session.execute(
685
+ select(func.count(Validation.id)).where(
686
+ Validation.created_at < cutoff,
687
+ Validation.result_json.contains(f'"tag": "{tag}"'),
688
+ )
689
+ )
690
+ count = count_result.scalar() or 0
691
+
692
+ if count > 0:
693
+ await session.execute(
694
+ delete(Validation).where(
695
+ Validation.created_at < cutoff,
696
+ Validation.result_json.contains(f'"tag": "{tag}"'),
697
+ )
698
+ )
699
+ total_deleted += count
700
+
701
+ logger.info(f"Tag cleanup: deleted {count} validations with tag '{tag}'")
702
+
703
+ duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
704
+
705
+ logger.info(f"Tag-based cleanup: deleted {total_deleted} total records")
706
+
707
+ return CleanupResult(
708
+ task_name=self.name,
709
+ records_deleted=total_deleted,
710
+ duration_ms=duration,
711
+ success=True,
712
+ )
713
+
714
+ except Exception as e:
715
+ duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
716
+ logger.error(f"Tag-based cleanup failed: {e}")
717
+ return CleanupResult(
718
+ task_name=self.name,
719
+ duration_ms=duration,
720
+ success=False,
721
+ error=str(e),
722
+ )
723
+
724
+
303
725
  @dataclass
304
726
  class MaintenanceReport:
305
727
  """Report of a complete maintenance run.
@@ -385,11 +807,27 @@ class MaintenanceManager:
385
807
  self._strategies.append(strategy)
386
808
 
387
809
  def register_default_strategies(self) -> None:
388
- """Register all default cleanup strategies."""
810
+ """Register all default cleanup strategies.
811
+
812
+ Registers 6 types of cleanup strategies:
813
+ - Time-based: ValidationCleanupStrategy, NotificationLogCleanupStrategy
814
+ - Count-based: ProfileCleanupStrategy
815
+ - Size-based: SizeBasedCleanupStrategy
816
+ - Status-based: StatusBasedCleanupStrategy
817
+ - Tag-based: TagBasedCleanupStrategy
818
+ """
389
819
  self._strategies = [
820
+ # Time-based policies
390
821
  ValidationCleanupStrategy(),
391
- ProfileCleanupStrategy(),
392
822
  NotificationLogCleanupStrategy(),
823
+ # Count-based policy
824
+ ProfileCleanupStrategy(),
825
+ # Size-based policy
826
+ SizeBasedCleanupStrategy(),
827
+ # Status-based policy
828
+ StatusBasedCleanupStrategy(),
829
+ # Tag-based policy
830
+ TagBasedCleanupStrategy(),
393
831
  ]
394
832
 
395
833
  async def run_cleanup(self) -> MaintenanceReport: