truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.1__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 (205) hide show
  1. truthound_dashboard/api/alerts.py +75 -86
  2. truthound_dashboard/api/anomaly.py +7 -13
  3. truthound_dashboard/api/cross_alerts.py +38 -52
  4. truthound_dashboard/api/drift.py +49 -59
  5. truthound_dashboard/api/drift_monitor.py +234 -79
  6. truthound_dashboard/api/enterprise_sampling.py +498 -0
  7. truthound_dashboard/api/history.py +57 -5
  8. truthound_dashboard/api/lineage.py +3 -48
  9. truthound_dashboard/api/maintenance.py +104 -49
  10. truthound_dashboard/api/mask.py +1 -2
  11. truthound_dashboard/api/middleware.py +2 -1
  12. truthound_dashboard/api/model_monitoring.py +435 -311
  13. truthound_dashboard/api/notifications.py +227 -191
  14. truthound_dashboard/api/notifications_advanced.py +21 -20
  15. truthound_dashboard/api/observability.py +586 -0
  16. truthound_dashboard/api/plugins.py +2 -433
  17. truthound_dashboard/api/profile.py +199 -37
  18. truthound_dashboard/api/quality_reporter.py +701 -0
  19. truthound_dashboard/api/reports.py +7 -16
  20. truthound_dashboard/api/router.py +66 -0
  21. truthound_dashboard/api/rule_suggestions.py +5 -5
  22. truthound_dashboard/api/scan.py +17 -19
  23. truthound_dashboard/api/schedules.py +85 -50
  24. truthound_dashboard/api/schema_evolution.py +6 -6
  25. truthound_dashboard/api/schema_watcher.py +667 -0
  26. truthound_dashboard/api/sources.py +98 -27
  27. truthound_dashboard/api/tiering.py +1323 -0
  28. truthound_dashboard/api/triggers.py +14 -11
  29. truthound_dashboard/api/validations.py +12 -11
  30. truthound_dashboard/api/versioning.py +1 -6
  31. truthound_dashboard/core/__init__.py +129 -3
  32. truthound_dashboard/core/actions/__init__.py +62 -0
  33. truthound_dashboard/core/actions/custom.py +426 -0
  34. truthound_dashboard/core/actions/notifications.py +910 -0
  35. truthound_dashboard/core/actions/storage.py +472 -0
  36. truthound_dashboard/core/actions/webhook.py +281 -0
  37. truthound_dashboard/core/anomaly.py +262 -67
  38. truthound_dashboard/core/anomaly_explainer.py +4 -3
  39. truthound_dashboard/core/backends/__init__.py +67 -0
  40. truthound_dashboard/core/backends/base.py +299 -0
  41. truthound_dashboard/core/backends/errors.py +191 -0
  42. truthound_dashboard/core/backends/factory.py +423 -0
  43. truthound_dashboard/core/backends/mock_backend.py +451 -0
  44. truthound_dashboard/core/backends/truthound_backend.py +718 -0
  45. truthound_dashboard/core/checkpoint/__init__.py +87 -0
  46. truthound_dashboard/core/checkpoint/adapters.py +814 -0
  47. truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
  48. truthound_dashboard/core/checkpoint/runner.py +270 -0
  49. truthound_dashboard/core/connections.py +645 -23
  50. truthound_dashboard/core/converters/__init__.py +14 -0
  51. truthound_dashboard/core/converters/truthound.py +620 -0
  52. truthound_dashboard/core/cross_alerts.py +540 -320
  53. truthound_dashboard/core/datasource_factory.py +1672 -0
  54. truthound_dashboard/core/drift_monitor.py +216 -20
  55. truthound_dashboard/core/enterprise_sampling.py +1291 -0
  56. truthound_dashboard/core/interfaces/__init__.py +225 -0
  57. truthound_dashboard/core/interfaces/actions.py +652 -0
  58. truthound_dashboard/core/interfaces/base.py +247 -0
  59. truthound_dashboard/core/interfaces/checkpoint.py +676 -0
  60. truthound_dashboard/core/interfaces/protocols.py +664 -0
  61. truthound_dashboard/core/interfaces/reporters.py +650 -0
  62. truthound_dashboard/core/interfaces/routing.py +646 -0
  63. truthound_dashboard/core/interfaces/triggers.py +619 -0
  64. truthound_dashboard/core/lineage.py +407 -71
  65. truthound_dashboard/core/model_monitoring.py +431 -3
  66. truthound_dashboard/core/notifications/base.py +4 -0
  67. truthound_dashboard/core/notifications/channels.py +501 -1203
  68. truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
  69. truthound_dashboard/core/notifications/deduplication/service.py +131 -348
  70. truthound_dashboard/core/notifications/dispatcher.py +202 -11
  71. truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
  72. truthound_dashboard/core/notifications/escalation/engine.py +168 -358
  73. truthound_dashboard/core/notifications/routing/__init__.py +88 -128
  74. truthound_dashboard/core/notifications/routing/engine.py +90 -317
  75. truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
  76. truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
  77. truthound_dashboard/core/notifications/throttling/builder.py +117 -255
  78. truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
  79. truthound_dashboard/core/phase5/collaboration.py +1 -1
  80. truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
  81. truthound_dashboard/core/quality_reporter.py +1359 -0
  82. truthound_dashboard/core/report_history.py +0 -6
  83. truthound_dashboard/core/reporters/__init__.py +175 -14
  84. truthound_dashboard/core/reporters/adapters.py +943 -0
  85. truthound_dashboard/core/reporters/base.py +0 -3
  86. truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
  87. truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
  88. truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
  89. truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
  90. truthound_dashboard/core/reporters/compat.py +266 -0
  91. truthound_dashboard/core/reporters/csv_reporter.py +2 -35
  92. truthound_dashboard/core/reporters/factory.py +526 -0
  93. truthound_dashboard/core/reporters/interfaces.py +745 -0
  94. truthound_dashboard/core/reporters/registry.py +1 -10
  95. truthound_dashboard/core/scheduler.py +165 -0
  96. truthound_dashboard/core/schema_evolution.py +3 -3
  97. truthound_dashboard/core/schema_watcher.py +1528 -0
  98. truthound_dashboard/core/services.py +595 -76
  99. truthound_dashboard/core/store_manager.py +810 -0
  100. truthound_dashboard/core/streaming_anomaly.py +169 -4
  101. truthound_dashboard/core/tiering.py +1309 -0
  102. truthound_dashboard/core/triggers/evaluators.py +178 -8
  103. truthound_dashboard/core/truthound_adapter.py +2620 -197
  104. truthound_dashboard/core/unified_alerts.py +23 -20
  105. truthound_dashboard/db/__init__.py +8 -0
  106. truthound_dashboard/db/database.py +8 -2
  107. truthound_dashboard/db/models.py +944 -25
  108. truthound_dashboard/db/repository.py +2 -0
  109. truthound_dashboard/main.py +15 -0
  110. truthound_dashboard/schemas/__init__.py +177 -16
  111. truthound_dashboard/schemas/base.py +44 -23
  112. truthound_dashboard/schemas/collaboration.py +19 -6
  113. truthound_dashboard/schemas/cross_alerts.py +19 -3
  114. truthound_dashboard/schemas/drift.py +61 -55
  115. truthound_dashboard/schemas/drift_monitor.py +67 -23
  116. truthound_dashboard/schemas/enterprise_sampling.py +653 -0
  117. truthound_dashboard/schemas/lineage.py +0 -33
  118. truthound_dashboard/schemas/mask.py +10 -8
  119. truthound_dashboard/schemas/model_monitoring.py +89 -10
  120. truthound_dashboard/schemas/notifications_advanced.py +13 -0
  121. truthound_dashboard/schemas/observability.py +453 -0
  122. truthound_dashboard/schemas/plugins.py +0 -280
  123. truthound_dashboard/schemas/profile.py +154 -247
  124. truthound_dashboard/schemas/quality_reporter.py +403 -0
  125. truthound_dashboard/schemas/reports.py +2 -2
  126. truthound_dashboard/schemas/rule_suggestion.py +8 -1
  127. truthound_dashboard/schemas/scan.py +4 -24
  128. truthound_dashboard/schemas/schedule.py +11 -3
  129. truthound_dashboard/schemas/schema_watcher.py +727 -0
  130. truthound_dashboard/schemas/source.py +17 -2
  131. truthound_dashboard/schemas/tiering.py +822 -0
  132. truthound_dashboard/schemas/triggers.py +16 -0
  133. truthound_dashboard/schemas/unified_alerts.py +7 -0
  134. truthound_dashboard/schemas/validation.py +0 -13
  135. truthound_dashboard/schemas/validators/base.py +41 -21
  136. truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
  137. truthound_dashboard/schemas/validators/localization_validators.py +273 -0
  138. truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
  139. truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
  140. truthound_dashboard/schemas/validators/referential_validators.py +312 -0
  141. truthound_dashboard/schemas/validators/registry.py +93 -8
  142. truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
  143. truthound_dashboard/schemas/versioning.py +1 -6
  144. truthound_dashboard/static/index.html +2 -2
  145. truthound_dashboard-1.5.1.dist-info/METADATA +312 -0
  146. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/RECORD +149 -148
  147. truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
  148. truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
  149. truthound_dashboard/core/plugins/hooks/manager.py +0 -403
  150. truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
  151. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
  152. truthound_dashboard/core/reporters/junit_reporter.py +0 -233
  153. truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
  154. truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
  155. truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
  156. truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
  157. truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
  158. truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
  159. truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
  160. truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
  161. truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
  162. truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
  163. truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
  164. truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
  165. truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
  166. truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
  167. truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
  168. truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
  169. truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
  170. truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
  171. truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
  172. truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
  173. truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
  174. truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
  175. truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
  176. truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
  177. truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
  178. truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
  179. truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
  180. truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
  181. truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
  182. truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
  183. truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
  184. truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
  185. truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
  186. truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
  187. truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
  188. truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
  189. truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
  190. truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
  191. truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
  192. truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
  193. truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
  194. truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
  195. truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
  196. truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
  197. truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
  198. truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
  199. truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
  200. truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
  201. truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
  202. truthound_dashboard-1.4.4.dist-info/METADATA +0 -507
  203. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/WHEEL +0 -0
  204. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/entry_points.txt +0 -0
  205. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1309 @@
1
+ """Storage Tiering Service.
2
+
3
+ This module provides storage tiering functionality using truthound's
4
+ storage tiering module (truthound.stores.tiering).
5
+
6
+ Architecture:
7
+ API Endpoints
8
+
9
+ TieringService
10
+
11
+ TieringAdapter
12
+
13
+ truthound.stores.tiering
14
+ - TierType
15
+ - StorageTier
16
+ - TieringConfig
17
+ - TierPolicy classes
18
+ - TierMetadataStore
19
+ - TieringResult
20
+
21
+ Features:
22
+ - Storage tier management (hot, warm, cold, archive)
23
+ - Policy-based migration (age, access, size, scheduled, composite, custom)
24
+ - Migration execution with truthound backends
25
+ - Access tracking for intelligent tiering
26
+ - Background policy evaluation and execution
27
+ - Migration history and statistics
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import logging
34
+ from concurrent.futures import ThreadPoolExecutor
35
+ from dataclasses import dataclass, field
36
+ from datetime import datetime, timedelta
37
+ from functools import partial
38
+ from typing import Any, Protocol, runtime_checkable
39
+ from uuid import uuid4
40
+
41
+ from sqlalchemy import func, select, update
42
+ from sqlalchemy.ext.asyncio import AsyncSession
43
+ from sqlalchemy.orm import selectinload
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # Thread pool for blocking truthound operations
48
+ _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="tiering")
49
+
50
+
51
+ def _generate_id() -> str:
52
+ """Generate a unique ID."""
53
+ return str(uuid4())
54
+
55
+
56
+ # =============================================================================
57
+ # Protocols for type checking
58
+ # =============================================================================
59
+
60
+
61
+ @runtime_checkable
62
+ class TruthoundStore(Protocol):
63
+ """Protocol for truthound store backends."""
64
+
65
+ def save(self, item_id: str, data: Any) -> str: ...
66
+ def get(self, item_id: str) -> Any: ...
67
+ def delete(self, item_id: str) -> bool: ...
68
+ def list(self) -> list[str]: ...
69
+
70
+
71
+ @runtime_checkable
72
+ class TruthoundTierPolicy(Protocol):
73
+ """Protocol for truthound tier policies."""
74
+
75
+ @property
76
+ def from_tier(self) -> str: ...
77
+
78
+ @property
79
+ def to_tier(self) -> str: ...
80
+
81
+ def should_migrate(self, info: Any) -> bool: ...
82
+
83
+
84
+ # =============================================================================
85
+ # Result dataclasses
86
+ # =============================================================================
87
+
88
+
89
+ @dataclass
90
+ class MigrationItem:
91
+ """Represents an item to be migrated."""
92
+
93
+ item_id: str
94
+ from_tier: str
95
+ to_tier: str
96
+ size_bytes: int = 0
97
+ policy_id: str | None = None
98
+ metadata: dict[str, Any] = field(default_factory=dict)
99
+
100
+
101
+ @dataclass
102
+ class MigrationResult:
103
+ """Result of a single migration operation."""
104
+
105
+ item_id: str
106
+ from_tier: str
107
+ to_tier: str
108
+ success: bool
109
+ size_bytes: int = 0
110
+ error_message: str | None = None
111
+ duration_ms: float = 0.0
112
+ started_at: datetime = field(default_factory=datetime.utcnow)
113
+ completed_at: datetime | None = None
114
+
115
+
116
+ @dataclass
117
+ class TieringExecutionResult:
118
+ """Result of a tiering execution run."""
119
+
120
+ start_time: datetime
121
+ end_time: datetime
122
+ items_scanned: int = 0
123
+ items_migrated: int = 0
124
+ items_failed: int = 0
125
+ bytes_migrated: int = 0
126
+ migrations: list[MigrationResult] = field(default_factory=list)
127
+ errors: list[str] = field(default_factory=list)
128
+ dry_run: bool = False
129
+
130
+ @property
131
+ def duration_seconds(self) -> float:
132
+ """Calculate execution duration in seconds."""
133
+ return (self.end_time - self.start_time).total_seconds()
134
+
135
+ @property
136
+ def success_rate(self) -> float:
137
+ """Calculate migration success rate."""
138
+ total = self.items_migrated + self.items_failed
139
+ return self.items_migrated / total if total > 0 else 1.0
140
+
141
+
142
+ @dataclass
143
+ class TierInfo:
144
+ """Metadata about an item's tier placement."""
145
+
146
+ item_id: str
147
+ tier_name: str
148
+ created_at: datetime
149
+ migrated_at: datetime | None = None
150
+ access_count: int = 0
151
+ last_accessed: datetime | None = None
152
+ size_bytes: int = 0
153
+ next_migration: datetime | None = None
154
+ metadata: dict[str, Any] = field(default_factory=dict)
155
+
156
+
157
+ # =============================================================================
158
+ # Tiering Adapter - truthound integration layer
159
+ # =============================================================================
160
+
161
+
162
+ class TieringAdapter:
163
+ """Adapter for truthound storage tiering functionality.
164
+
165
+ This adapter provides a clean interface between the dashboard and
166
+ truthound's tiering module, handling:
167
+ - Store backend creation
168
+ - Policy object creation
169
+ - Migration execution
170
+ - Metadata tracking
171
+
172
+ The adapter gracefully handles cases where truthound is unavailable
173
+ or lacks certain features.
174
+ """
175
+
176
+ _truthound_available: bool | None = None
177
+ _tiering_available: bool | None = None
178
+
179
+ def __init__(self) -> None:
180
+ """Initialize adapter."""
181
+ self._stores: dict[str, Any] = {}
182
+ self._metadata_store: Any = None
183
+ self._check_availability()
184
+
185
+ def _check_availability(self) -> None:
186
+ """Check truthound tiering availability."""
187
+ if TieringAdapter._truthound_available is None:
188
+ try:
189
+ import truthound
190
+ TieringAdapter._truthound_available = True
191
+ except ImportError:
192
+ TieringAdapter._truthound_available = False
193
+ logger.warning("truthound not available, using fallback implementation")
194
+
195
+ if TieringAdapter._tiering_available is None and TieringAdapter._truthound_available:
196
+ try:
197
+ from truthound.stores.tiering.base import TierType, StorageTier, TieringConfig
198
+ from truthound.stores.tiering.policies import AgeBasedTierPolicy
199
+ TieringAdapter._tiering_available = True
200
+ except ImportError:
201
+ TieringAdapter._tiering_available = False
202
+ logger.warning("truthound.stores.tiering not available, using fallback")
203
+
204
+ @property
205
+ def is_available(self) -> bool:
206
+ """Check if truthound tiering is available."""
207
+ return TieringAdapter._tiering_available or False
208
+
209
+ def create_store(
210
+ self,
211
+ store_type: str,
212
+ store_config: dict[str, Any],
213
+ ) -> Any:
214
+ """Create a truthound store backend.
215
+
216
+ Args:
217
+ store_type: Type of store (filesystem, s3, gcs, etc.)
218
+ store_config: Store configuration.
219
+
220
+ Returns:
221
+ Store instance or fallback store.
222
+ """
223
+ if not self.is_available:
224
+ return self._create_fallback_store(store_type, store_config)
225
+
226
+ try:
227
+ from truthound.stores import get_store
228
+ return get_store(store_type, **store_config)
229
+ except Exception as e:
230
+ logger.warning(f"Failed to create truthound store: {e}")
231
+ return self._create_fallback_store(store_type, store_config)
232
+
233
+ def _create_fallback_store(
234
+ self,
235
+ store_type: str,
236
+ store_config: dict[str, Any],
237
+ ) -> "FallbackStore":
238
+ """Create a fallback store for when truthound is unavailable."""
239
+ return FallbackStore(store_type, store_config)
240
+
241
+ def create_storage_tier(
242
+ self,
243
+ name: str,
244
+ store: Any,
245
+ tier_type: str,
246
+ priority: int = 1,
247
+ cost_per_gb: float | None = None,
248
+ retrieval_time_ms: int | None = None,
249
+ metadata: dict[str, Any] | None = None,
250
+ ) -> Any:
251
+ """Create a truthound StorageTier.
252
+
253
+ Args:
254
+ name: Tier name.
255
+ store: Store backend instance.
256
+ tier_type: Tier type (hot, warm, cold, archive).
257
+ priority: Read order priority.
258
+ cost_per_gb: Cost per GB for analysis.
259
+ retrieval_time_ms: Expected retrieval latency.
260
+ metadata: Additional metadata.
261
+
262
+ Returns:
263
+ StorageTier instance or dataclass fallback.
264
+ """
265
+ if not self.is_available:
266
+ return FallbackStorageTier(
267
+ name=name,
268
+ store=store,
269
+ tier_type=tier_type,
270
+ priority=priority,
271
+ cost_per_gb=cost_per_gb,
272
+ retrieval_time_ms=retrieval_time_ms,
273
+ metadata=metadata or {},
274
+ )
275
+
276
+ try:
277
+ from truthound.stores.tiering.base import StorageTier, TierType as TruthoundTierType
278
+
279
+ tier_type_enum = getattr(TruthoundTierType, tier_type.upper(), TruthoundTierType.HOT)
280
+
281
+ return StorageTier(
282
+ name=name,
283
+ store=store,
284
+ tier_type=tier_type_enum,
285
+ priority=priority,
286
+ cost_per_gb=cost_per_gb,
287
+ retrieval_time_ms=retrieval_time_ms,
288
+ metadata=metadata or {},
289
+ )
290
+ except Exception as e:
291
+ logger.warning(f"Failed to create truthound StorageTier: {e}")
292
+ return FallbackStorageTier(
293
+ name=name,
294
+ store=store,
295
+ tier_type=tier_type,
296
+ priority=priority,
297
+ cost_per_gb=cost_per_gb,
298
+ retrieval_time_ms=retrieval_time_ms,
299
+ metadata=metadata or {},
300
+ )
301
+
302
+ def create_policy(
303
+ self,
304
+ policy_type: str,
305
+ from_tier: str,
306
+ to_tier: str,
307
+ config: dict[str, Any],
308
+ direction: str = "demote",
309
+ ) -> Any:
310
+ """Create a truthound tier policy.
311
+
312
+ Args:
313
+ policy_type: Type of policy (age_based, access_based, etc.)
314
+ from_tier: Source tier name.
315
+ to_tier: Destination tier name.
316
+ config: Policy-specific configuration.
317
+ direction: Migration direction (demote/promote).
318
+
319
+ Returns:
320
+ TierPolicy instance or fallback.
321
+ """
322
+ if not self.is_available:
323
+ return self._create_fallback_policy(
324
+ policy_type, from_tier, to_tier, config, direction
325
+ )
326
+
327
+ try:
328
+ return self._create_truthound_policy(
329
+ policy_type, from_tier, to_tier, config, direction
330
+ )
331
+ except Exception as e:
332
+ logger.warning(f"Failed to create truthound policy: {e}")
333
+ return self._create_fallback_policy(
334
+ policy_type, from_tier, to_tier, config, direction
335
+ )
336
+
337
+ def _create_truthound_policy(
338
+ self,
339
+ policy_type: str,
340
+ from_tier: str,
341
+ to_tier: str,
342
+ config: dict[str, Any],
343
+ direction: str,
344
+ ) -> Any:
345
+ """Create a truthound policy from type and config."""
346
+ from truthound.stores.tiering.base import MigrationDirection as TruthoundDirection
347
+ from truthound.stores.tiering import policies as th_policies
348
+
349
+ direction_enum = (
350
+ TruthoundDirection.PROMOTE
351
+ if direction == "promote"
352
+ else TruthoundDirection.DEMOTE
353
+ )
354
+
355
+ if policy_type == "age_based":
356
+ return th_policies.AgeBasedTierPolicy(
357
+ from_tier=from_tier,
358
+ to_tier=to_tier,
359
+ after_days=config.get("after_days", 0),
360
+ after_hours=config.get("after_hours", 0),
361
+ direction=direction_enum,
362
+ )
363
+ elif policy_type == "access_based":
364
+ return th_policies.AccessBasedTierPolicy(
365
+ from_tier=from_tier,
366
+ to_tier=to_tier,
367
+ inactive_days=config.get("inactive_days"),
368
+ min_access_count=config.get("min_access_count"),
369
+ access_window_days=config.get("access_window_days", 7),
370
+ direction=direction_enum,
371
+ )
372
+ elif policy_type == "size_based":
373
+ return th_policies.SizeBasedTierPolicy(
374
+ from_tier=from_tier,
375
+ to_tier=to_tier,
376
+ min_size_bytes=config.get("min_size_bytes", 0),
377
+ min_size_kb=config.get("min_size_kb", 0),
378
+ min_size_mb=config.get("min_size_mb", 0),
379
+ min_size_gb=config.get("min_size_gb", 0),
380
+ tier_max_size_bytes=config.get("tier_max_size_bytes", 0),
381
+ tier_max_size_gb=config.get("tier_max_size_gb", 0),
382
+ direction=direction_enum,
383
+ )
384
+ elif policy_type == "scheduled":
385
+ return th_policies.ScheduledTierPolicy(
386
+ from_tier=from_tier,
387
+ to_tier=to_tier,
388
+ on_days=config.get("on_days"),
389
+ at_hour=config.get("at_hour"),
390
+ min_age_days=config.get("min_age_days", 0),
391
+ direction=direction_enum,
392
+ )
393
+ elif policy_type == "custom":
394
+ # Custom policies use a predicate function
395
+ predicate_expr = config.get("predicate_expression", "False")
396
+
397
+ def custom_predicate(info: Any) -> bool:
398
+ try:
399
+ # Safe evaluation with limited scope
400
+ local_vars = {
401
+ "info": info,
402
+ "size_bytes": getattr(info, "size_bytes", 0),
403
+ "access_count": getattr(info, "access_count", 0),
404
+ "created_at": getattr(info, "created_at", None),
405
+ "last_accessed": getattr(info, "last_accessed", None),
406
+ }
407
+ return bool(eval(predicate_expr, {"__builtins__": {}}, local_vars))
408
+ except Exception:
409
+ return False
410
+
411
+ return th_policies.CustomTierPolicy(
412
+ from_tier=from_tier,
413
+ to_tier=to_tier,
414
+ predicate=custom_predicate,
415
+ description=config.get("description", ""),
416
+ )
417
+ else:
418
+ raise ValueError(f"Unknown policy type: {policy_type}")
419
+
420
+ def _create_fallback_policy(
421
+ self,
422
+ policy_type: str,
423
+ from_tier: str,
424
+ to_tier: str,
425
+ config: dict[str, Any],
426
+ direction: str,
427
+ ) -> "FallbackPolicy":
428
+ """Create a fallback policy."""
429
+ return FallbackPolicy(
430
+ policy_type=policy_type,
431
+ from_tier=from_tier,
432
+ to_tier=to_tier,
433
+ config=config,
434
+ direction=direction,
435
+ )
436
+
437
+ def create_metadata_store(self) -> Any:
438
+ """Create or get the tier metadata store.
439
+
440
+ Returns:
441
+ TierMetadataStore instance.
442
+ """
443
+ if self._metadata_store is not None:
444
+ return self._metadata_store
445
+
446
+ if self.is_available:
447
+ try:
448
+ from truthound.stores.tiering.base import InMemoryTierMetadataStore
449
+ self._metadata_store = InMemoryTierMetadataStore()
450
+ except Exception as e:
451
+ logger.warning(f"Failed to create truthound metadata store: {e}")
452
+ self._metadata_store = FallbackMetadataStore()
453
+ else:
454
+ self._metadata_store = FallbackMetadataStore()
455
+
456
+ return self._metadata_store
457
+
458
+ async def execute_migration(
459
+ self,
460
+ item_id: str,
461
+ from_store: Any,
462
+ to_store: Any,
463
+ metadata_store: Any,
464
+ ) -> MigrationResult:
465
+ """Execute a single item migration.
466
+
467
+ Args:
468
+ item_id: ID of item to migrate.
469
+ from_store: Source store.
470
+ to_store: Destination store.
471
+ metadata_store: Metadata store for tracking.
472
+
473
+ Returns:
474
+ MigrationResult with migration details.
475
+ """
476
+ started_at = datetime.utcnow()
477
+ from_tier = getattr(from_store, "name", "unknown")
478
+ to_tier = getattr(to_store, "name", "unknown")
479
+
480
+ try:
481
+ # Run migration in thread pool to avoid blocking
482
+ loop = asyncio.get_event_loop()
483
+ result = await loop.run_in_executor(
484
+ _executor,
485
+ partial(self._sync_migrate, item_id, from_store, to_store, metadata_store),
486
+ )
487
+ completed_at = datetime.utcnow()
488
+ duration_ms = (completed_at - started_at).total_seconds() * 1000
489
+
490
+ return MigrationResult(
491
+ item_id=item_id,
492
+ from_tier=from_tier,
493
+ to_tier=to_tier,
494
+ success=result.get("success", False),
495
+ size_bytes=result.get("size_bytes", 0),
496
+ error_message=result.get("error"),
497
+ duration_ms=duration_ms,
498
+ started_at=started_at,
499
+ completed_at=completed_at,
500
+ )
501
+ except Exception as e:
502
+ completed_at = datetime.utcnow()
503
+ duration_ms = (completed_at - started_at).total_seconds() * 1000
504
+ return MigrationResult(
505
+ item_id=item_id,
506
+ from_tier=from_tier,
507
+ to_tier=to_tier,
508
+ success=False,
509
+ error_message=str(e),
510
+ duration_ms=duration_ms,
511
+ started_at=started_at,
512
+ completed_at=completed_at,
513
+ )
514
+
515
+ def _sync_migrate(
516
+ self,
517
+ item_id: str,
518
+ from_store: Any,
519
+ to_store: Any,
520
+ metadata_store: Any,
521
+ ) -> dict[str, Any]:
522
+ """Synchronous migration operation (runs in thread pool)."""
523
+ try:
524
+ # Get item from source
525
+ data = from_store.get(item_id)
526
+ if data is None:
527
+ return {"success": False, "error": f"Item {item_id} not found in source tier"}
528
+
529
+ # Calculate size
530
+ size_bytes = len(str(data).encode("utf-8")) if data else 0
531
+
532
+ # Save to destination
533
+ to_store.save(item_id, data)
534
+
535
+ # Delete from source
536
+ from_store.delete(item_id)
537
+
538
+ # Update metadata
539
+ if metadata_store:
540
+ info = metadata_store.get_info(item_id)
541
+ if info:
542
+ info.tier_name = getattr(to_store, "name", "unknown")
543
+ info.migrated_at = datetime.utcnow()
544
+ metadata_store.save_info(info)
545
+
546
+ return {"success": True, "size_bytes": size_bytes}
547
+ except Exception as e:
548
+ return {"success": False, "error": str(e)}
549
+
550
+ def evaluate_policy(
551
+ self,
552
+ policy: Any,
553
+ tier_info: TierInfo,
554
+ ) -> bool:
555
+ """Evaluate if an item should be migrated by a policy.
556
+
557
+ Args:
558
+ policy: Tier policy to evaluate.
559
+ tier_info: Item's tier information.
560
+
561
+ Returns:
562
+ True if item should be migrated.
563
+ """
564
+ try:
565
+ if hasattr(policy, "should_migrate"):
566
+ return policy.should_migrate(tier_info)
567
+ elif isinstance(policy, FallbackPolicy):
568
+ return policy.evaluate(tier_info)
569
+ return False
570
+ except Exception as e:
571
+ logger.warning(f"Policy evaluation failed: {e}")
572
+ return False
573
+
574
+
575
+ # =============================================================================
576
+ # Fallback implementations
577
+ # =============================================================================
578
+
579
+
580
+ @dataclass
581
+ class FallbackStore:
582
+ """Fallback store implementation when truthound is unavailable."""
583
+
584
+ store_type: str
585
+ config: dict[str, Any]
586
+ _data: dict[str, Any] = field(default_factory=dict)
587
+
588
+ def save(self, item_id: str, data: Any) -> str:
589
+ self._data[item_id] = data
590
+ return item_id
591
+
592
+ def get(self, item_id: str) -> Any:
593
+ return self._data.get(item_id)
594
+
595
+ def delete(self, item_id: str) -> bool:
596
+ if item_id in self._data:
597
+ del self._data[item_id]
598
+ return True
599
+ return False
600
+
601
+ def list(self) -> list[str]:
602
+ return list(self._data.keys())
603
+
604
+
605
+ @dataclass
606
+ class FallbackStorageTier:
607
+ """Fallback StorageTier when truthound is unavailable."""
608
+
609
+ name: str
610
+ store: Any
611
+ tier_type: str
612
+ priority: int = 1
613
+ cost_per_gb: float | None = None
614
+ retrieval_time_ms: int | None = None
615
+ metadata: dict[str, Any] = field(default_factory=dict)
616
+
617
+
618
+ @dataclass
619
+ class FallbackPolicy:
620
+ """Fallback policy implementation."""
621
+
622
+ policy_type: str
623
+ from_tier: str
624
+ to_tier: str
625
+ config: dict[str, Any]
626
+ direction: str = "demote"
627
+
628
+ def evaluate(self, info: TierInfo) -> bool:
629
+ """Evaluate if item should be migrated."""
630
+ now = datetime.utcnow()
631
+
632
+ if self.policy_type == "age_based":
633
+ after_days = self.config.get("after_days", 0)
634
+ after_hours = self.config.get("after_hours", 0)
635
+ threshold = timedelta(days=after_days, hours=after_hours)
636
+ age = now - info.created_at
637
+ return age >= threshold
638
+
639
+ elif self.policy_type == "access_based":
640
+ inactive_days = self.config.get("inactive_days")
641
+ min_access_count = self.config.get("min_access_count")
642
+
643
+ if inactive_days and info.last_accessed:
644
+ inactive_time = now - info.last_accessed
645
+ if inactive_time >= timedelta(days=inactive_days):
646
+ return True
647
+
648
+ if min_access_count:
649
+ return info.access_count >= min_access_count
650
+
651
+ return False
652
+
653
+ elif self.policy_type == "size_based":
654
+ total_min_bytes = (
655
+ self.config.get("min_size_bytes", 0)
656
+ + self.config.get("min_size_kb", 0) * 1024
657
+ + self.config.get("min_size_mb", 0) * 1024 * 1024
658
+ + self.config.get("min_size_gb", 0) * 1024 * 1024 * 1024
659
+ )
660
+ return info.size_bytes >= total_min_bytes
661
+
662
+ elif self.policy_type == "scheduled":
663
+ on_days = self.config.get("on_days")
664
+ at_hour = self.config.get("at_hour")
665
+ min_age_days = self.config.get("min_age_days", 0)
666
+
667
+ if on_days and now.weekday() not in on_days:
668
+ return False
669
+ if at_hour is not None and now.hour != at_hour:
670
+ return False
671
+ if min_age_days:
672
+ age = now - info.created_at
673
+ if age < timedelta(days=min_age_days):
674
+ return False
675
+ return True
676
+
677
+ elif self.policy_type == "custom":
678
+ predicate_expr = self.config.get("predicate_expression", "False")
679
+ try:
680
+ local_vars = {
681
+ "info": info,
682
+ "size_bytes": info.size_bytes,
683
+ "access_count": info.access_count,
684
+ "created_at": info.created_at,
685
+ "last_accessed": info.last_accessed,
686
+ }
687
+ return bool(eval(predicate_expr, {"__builtins__": {}}, local_vars))
688
+ except Exception:
689
+ return False
690
+
691
+ return False
692
+
693
+ def should_migrate(self, info: Any) -> bool:
694
+ """Alias for evaluate for protocol compatibility."""
695
+ if isinstance(info, TierInfo):
696
+ return self.evaluate(info)
697
+ # Convert to TierInfo if needed
698
+ tier_info = TierInfo(
699
+ item_id=getattr(info, "item_id", ""),
700
+ tier_name=getattr(info, "tier_name", ""),
701
+ created_at=getattr(info, "created_at", datetime.utcnow()),
702
+ migrated_at=getattr(info, "migrated_at", None),
703
+ access_count=getattr(info, "access_count", 0),
704
+ last_accessed=getattr(info, "last_accessed", None),
705
+ size_bytes=getattr(info, "size_bytes", 0),
706
+ )
707
+ return self.evaluate(tier_info)
708
+
709
+
710
+ class FallbackMetadataStore:
711
+ """Fallback metadata store when truthound is unavailable."""
712
+
713
+ def __init__(self) -> None:
714
+ self._data: dict[str, TierInfo] = {}
715
+
716
+ def save_info(self, info: TierInfo) -> None:
717
+ self._data[info.item_id] = info
718
+
719
+ def get_info(self, item_id: str) -> TierInfo | None:
720
+ return self._data.get(item_id)
721
+
722
+ def delete_info(self, item_id: str) -> bool:
723
+ if item_id in self._data:
724
+ del self._data[item_id]
725
+ return True
726
+ return False
727
+
728
+ def list_by_tier(self, tier_name: str) -> list[TierInfo]:
729
+ return [info for info in self._data.values() if info.tier_name == tier_name]
730
+
731
+ def update_access(self, item_id: str) -> None:
732
+ if item_id in self._data:
733
+ self._data[item_id].access_count += 1
734
+ self._data[item_id].last_accessed = datetime.utcnow()
735
+
736
+
737
+ # =============================================================================
738
+ # Singleton adapter instance
739
+ # =============================================================================
740
+
741
+ _adapter_instance: TieringAdapter | None = None
742
+
743
+
744
+ def get_tiering_adapter() -> TieringAdapter:
745
+ """Get the singleton tiering adapter instance."""
746
+ global _adapter_instance
747
+ if _adapter_instance is None:
748
+ _adapter_instance = TieringAdapter()
749
+ return _adapter_instance
750
+
751
+
752
+ # =============================================================================
753
+ # Tiering Service
754
+ # =============================================================================
755
+
756
+
757
+ class TieringService:
758
+ """Service for storage tiering operations.
759
+
760
+ This service provides comprehensive storage tiering using truthound's
761
+ tiering module. It manages:
762
+ - Tier configuration and lifecycle
763
+ - Policy evaluation and execution
764
+ - Migration operations
765
+ - Statistics and reporting
766
+ - Background processing
767
+
768
+ The service uses TieringAdapter to interact with truthound,
769
+ maintaining loose coupling with the underlying library.
770
+ """
771
+
772
+ def __init__(self, session: AsyncSession) -> None:
773
+ """Initialize service.
774
+
775
+ Args:
776
+ session: Async database session.
777
+ """
778
+ self._session = session
779
+ self._adapter = get_tiering_adapter()
780
+ self._tier_stores: dict[str, Any] = {}
781
+ self._tier_objects: dict[str, Any] = {}
782
+ self._policies: dict[str, Any] = {}
783
+
784
+ # =========================================================================
785
+ # Tier Management
786
+ # =========================================================================
787
+
788
+ async def initialize_tier(self, tier_id: str) -> Any:
789
+ """Initialize a tier's store backend.
790
+
791
+ Args:
792
+ tier_id: Tier ID from database.
793
+
794
+ Returns:
795
+ Initialized StorageTier object.
796
+ """
797
+ from truthound_dashboard.db.models import StorageTierModel
798
+
799
+ # Check cache
800
+ if tier_id in self._tier_objects:
801
+ return self._tier_objects[tier_id]
802
+
803
+ # Load from database
804
+ tier = await self._session.get(StorageTierModel, tier_id)
805
+ if not tier:
806
+ raise ValueError(f"Tier '{tier_id}' not found")
807
+
808
+ # Create store backend
809
+ store = self._adapter.create_store(tier.store_type, tier.store_config)
810
+ self._tier_stores[tier_id] = store
811
+
812
+ # Create StorageTier object
813
+ tier_obj = self._adapter.create_storage_tier(
814
+ name=tier.name,
815
+ store=store,
816
+ tier_type=tier.tier_type,
817
+ priority=tier.priority,
818
+ cost_per_gb=tier.cost_per_gb,
819
+ retrieval_time_ms=tier.retrieval_time_ms,
820
+ metadata=tier.metadata,
821
+ )
822
+ self._tier_objects[tier_id] = tier_obj
823
+
824
+ return tier_obj
825
+
826
+ async def get_tier_store(self, tier_id: str) -> Any:
827
+ """Get the store backend for a tier.
828
+
829
+ Args:
830
+ tier_id: Tier ID.
831
+
832
+ Returns:
833
+ Store backend instance.
834
+ """
835
+ if tier_id not in self._tier_stores:
836
+ await self.initialize_tier(tier_id)
837
+ return self._tier_stores.get(tier_id)
838
+
839
+ # =========================================================================
840
+ # Policy Management
841
+ # =========================================================================
842
+
843
+ async def initialize_policy(self, policy_id: str) -> Any:
844
+ """Initialize a policy object.
845
+
846
+ Args:
847
+ policy_id: Policy ID from database.
848
+
849
+ Returns:
850
+ Initialized policy object.
851
+ """
852
+ from truthound_dashboard.db.models import TierPolicyModel
853
+
854
+ # Check cache
855
+ if policy_id in self._policies:
856
+ return self._policies[policy_id]
857
+
858
+ # Load from database
859
+ result = await self._session.execute(
860
+ select(TierPolicyModel)
861
+ .options(
862
+ selectinload(TierPolicyModel.from_tier),
863
+ selectinload(TierPolicyModel.to_tier),
864
+ selectinload(TierPolicyModel.children),
865
+ )
866
+ .where(TierPolicyModel.id == policy_id)
867
+ )
868
+ policy = result.scalar_one_or_none()
869
+ if not policy:
870
+ raise ValueError(f"Policy '{policy_id}' not found")
871
+
872
+ # Handle composite policies
873
+ if policy.policy_type == "composite":
874
+ return await self._initialize_composite_policy(policy)
875
+
876
+ # Create policy object
877
+ policy_obj = self._adapter.create_policy(
878
+ policy_type=policy.policy_type,
879
+ from_tier=policy.from_tier.name if policy.from_tier else "",
880
+ to_tier=policy.to_tier.name if policy.to_tier else "",
881
+ config=policy.config,
882
+ direction=policy.direction,
883
+ )
884
+ self._policies[policy_id] = policy_obj
885
+
886
+ return policy_obj
887
+
888
+ async def _initialize_composite_policy(self, policy: Any) -> Any:
889
+ """Initialize a composite policy with children.
890
+
891
+ Args:
892
+ policy: Composite policy model.
893
+
894
+ Returns:
895
+ Composite policy object.
896
+ """
897
+ if not self._adapter.is_available:
898
+ # Create fallback composite
899
+ child_policies = []
900
+ for child in policy.children:
901
+ child_obj = self._adapter.create_policy(
902
+ policy_type=child.policy_type,
903
+ from_tier=child.from_tier.name if child.from_tier else "",
904
+ to_tier=child.to_tier.name if child.to_tier else "",
905
+ config=child.config,
906
+ direction=child.direction,
907
+ )
908
+ child_policies.append(child_obj)
909
+
910
+ return FallbackCompositePolicy(
911
+ from_tier=policy.from_tier.name if policy.from_tier else "",
912
+ to_tier=policy.to_tier.name if policy.to_tier else "",
913
+ policies=child_policies,
914
+ require_all=policy.config.get("require_all", True),
915
+ direction=policy.direction,
916
+ )
917
+
918
+ try:
919
+ from truthound.stores.tiering.policies import CompositeTierPolicy
920
+ from truthound.stores.tiering.base import MigrationDirection as TruthoundDirection
921
+
922
+ direction_enum = (
923
+ TruthoundDirection.PROMOTE
924
+ if policy.direction == "promote"
925
+ else TruthoundDirection.DEMOTE
926
+ )
927
+
928
+ # Initialize child policies
929
+ child_policies = []
930
+ for child in policy.children:
931
+ child_obj = await self.initialize_policy(child.id)
932
+ child_policies.append(child_obj)
933
+
934
+ return CompositeTierPolicy(
935
+ from_tier=policy.from_tier.name,
936
+ to_tier=policy.to_tier.name,
937
+ policies=child_policies,
938
+ require_all=policy.config.get("require_all", True),
939
+ direction=direction_enum,
940
+ )
941
+ except Exception as e:
942
+ logger.warning(f"Failed to create composite policy: {e}")
943
+ # Fallback
944
+ child_policies = []
945
+ for child in policy.children:
946
+ child_obj = self._adapter.create_policy(
947
+ policy_type=child.policy_type,
948
+ from_tier=child.from_tier.name if child.from_tier else "",
949
+ to_tier=child.to_tier.name if child.to_tier else "",
950
+ config=child.config,
951
+ direction=child.direction,
952
+ )
953
+ child_policies.append(child_obj)
954
+
955
+ return FallbackCompositePolicy(
956
+ from_tier=policy.from_tier.name if policy.from_tier else "",
957
+ to_tier=policy.to_tier.name if policy.to_tier else "",
958
+ policies=child_policies,
959
+ require_all=policy.config.get("require_all", True),
960
+ direction=policy.direction,
961
+ )
962
+
963
+ # =========================================================================
964
+ # Migration Execution
965
+ # =========================================================================
966
+
967
+ async def execute_policy(
968
+ self,
969
+ policy_id: str,
970
+ dry_run: bool = False,
971
+ batch_size: int = 100,
972
+ ) -> TieringExecutionResult:
973
+ """Execute a tier policy.
974
+
975
+ Args:
976
+ policy_id: Policy ID to execute.
977
+ dry_run: If True, don't actually migrate, just report what would happen.
978
+ batch_size: Maximum items to process.
979
+
980
+ Returns:
981
+ Execution result with migration details.
982
+ """
983
+ from truthound_dashboard.db.models import TierPolicyModel, TierMigrationHistoryModel
984
+
985
+ start_time = datetime.utcnow()
986
+ result = TieringExecutionResult(
987
+ start_time=start_time,
988
+ end_time=start_time,
989
+ dry_run=dry_run,
990
+ )
991
+
992
+ try:
993
+ # Load policy
994
+ db_policy = await self._session.execute(
995
+ select(TierPolicyModel)
996
+ .options(
997
+ selectinload(TierPolicyModel.from_tier),
998
+ selectinload(TierPolicyModel.to_tier),
999
+ )
1000
+ .where(TierPolicyModel.id == policy_id)
1001
+ )
1002
+ policy_model = db_policy.scalar_one_or_none()
1003
+ if not policy_model:
1004
+ result.errors.append(f"Policy '{policy_id}' not found")
1005
+ result.end_time = datetime.utcnow()
1006
+ return result
1007
+
1008
+ if not policy_model.is_active:
1009
+ result.errors.append(f"Policy '{policy_model.name}' is not active")
1010
+ result.end_time = datetime.utcnow()
1011
+ return result
1012
+
1013
+ # Initialize tiers
1014
+ from_tier = await self.initialize_tier(policy_model.from_tier_id)
1015
+ to_tier = await self.initialize_tier(policy_model.to_tier_id)
1016
+ from_store = self._tier_stores[policy_model.from_tier_id]
1017
+ to_store = self._tier_stores[policy_model.to_tier_id]
1018
+
1019
+ # Initialize policy
1020
+ policy_obj = await self.initialize_policy(policy_id)
1021
+
1022
+ # Get metadata store
1023
+ metadata_store = self._adapter.create_metadata_store()
1024
+
1025
+ # Get items in source tier
1026
+ items_to_check = self._get_tier_items(from_store, metadata_store, batch_size)
1027
+ result.items_scanned = len(items_to_check)
1028
+
1029
+ # Evaluate and migrate
1030
+ for item_info in items_to_check:
1031
+ should_migrate = self._adapter.evaluate_policy(policy_obj, item_info)
1032
+
1033
+ if should_migrate:
1034
+ if dry_run:
1035
+ result.items_migrated += 1
1036
+ result.bytes_migrated += item_info.size_bytes
1037
+ result.migrations.append(
1038
+ MigrationResult(
1039
+ item_id=item_info.item_id,
1040
+ from_tier=policy_model.from_tier.name,
1041
+ to_tier=policy_model.to_tier.name,
1042
+ success=True,
1043
+ size_bytes=item_info.size_bytes,
1044
+ )
1045
+ )
1046
+ else:
1047
+ # Execute actual migration
1048
+ migration_result = await self._adapter.execute_migration(
1049
+ item_id=item_info.item_id,
1050
+ from_store=from_store,
1051
+ to_store=to_store,
1052
+ metadata_store=metadata_store,
1053
+ )
1054
+ result.migrations.append(migration_result)
1055
+
1056
+ if migration_result.success:
1057
+ result.items_migrated += 1
1058
+ result.bytes_migrated += migration_result.size_bytes
1059
+
1060
+ # Record in history
1061
+ history_entry = TierMigrationHistoryModel(
1062
+ id=_generate_id(),
1063
+ policy_id=policy_id,
1064
+ item_id=item_info.item_id,
1065
+ from_tier_id=policy_model.from_tier_id,
1066
+ to_tier_id=policy_model.to_tier_id,
1067
+ size_bytes=migration_result.size_bytes,
1068
+ started_at=migration_result.started_at,
1069
+ completed_at=migration_result.completed_at,
1070
+ status="completed",
1071
+ )
1072
+ self._session.add(history_entry)
1073
+ else:
1074
+ result.items_failed += 1
1075
+ result.errors.append(
1076
+ f"Migration failed for {item_info.item_id}: {migration_result.error_message}"
1077
+ )
1078
+
1079
+ # Record failure
1080
+ history_entry = TierMigrationHistoryModel(
1081
+ id=_generate_id(),
1082
+ policy_id=policy_id,
1083
+ item_id=item_info.item_id,
1084
+ from_tier_id=policy_model.from_tier_id,
1085
+ to_tier_id=policy_model.to_tier_id,
1086
+ size_bytes=0,
1087
+ started_at=migration_result.started_at,
1088
+ completed_at=migration_result.completed_at,
1089
+ status="failed",
1090
+ error_message=migration_result.error_message,
1091
+ )
1092
+ self._session.add(history_entry)
1093
+
1094
+ if not dry_run:
1095
+ await self._session.commit()
1096
+
1097
+ except Exception as e:
1098
+ logger.exception(f"Policy execution failed: {e}")
1099
+ result.errors.append(str(e))
1100
+
1101
+ result.end_time = datetime.utcnow()
1102
+ return result
1103
+
1104
+ def _get_tier_items(
1105
+ self,
1106
+ store: Any,
1107
+ metadata_store: Any,
1108
+ limit: int,
1109
+ ) -> list[TierInfo]:
1110
+ """Get items in a tier with their metadata.
1111
+
1112
+ Args:
1113
+ store: Store backend.
1114
+ metadata_store: Metadata store.
1115
+ limit: Maximum items to return.
1116
+
1117
+ Returns:
1118
+ List of TierInfo objects.
1119
+ """
1120
+ items = []
1121
+ tier_name = getattr(store, "name", "unknown")
1122
+
1123
+ try:
1124
+ item_ids = store.list()[:limit] if hasattr(store, "list") else []
1125
+
1126
+ for item_id in item_ids:
1127
+ info = metadata_store.get_info(item_id)
1128
+ if info:
1129
+ items.append(info)
1130
+ else:
1131
+ # Create basic info if not tracked
1132
+ items.append(
1133
+ TierInfo(
1134
+ item_id=item_id,
1135
+ tier_name=tier_name,
1136
+ created_at=datetime.utcnow(),
1137
+ )
1138
+ )
1139
+ except Exception as e:
1140
+ logger.warning(f"Failed to list tier items: {e}")
1141
+
1142
+ return items
1143
+
1144
+ async def migrate_item(
1145
+ self,
1146
+ item_id: str,
1147
+ from_tier_id: str,
1148
+ to_tier_id: str,
1149
+ ) -> MigrationResult:
1150
+ """Migrate a single item between tiers.
1151
+
1152
+ Args:
1153
+ item_id: Item to migrate.
1154
+ from_tier_id: Source tier ID.
1155
+ to_tier_id: Destination tier ID.
1156
+
1157
+ Returns:
1158
+ Migration result.
1159
+ """
1160
+ from truthound_dashboard.db.models import TierMigrationHistoryModel
1161
+
1162
+ from_store = await self.get_tier_store(from_tier_id)
1163
+ to_store = await self.get_tier_store(to_tier_id)
1164
+ metadata_store = self._adapter.create_metadata_store()
1165
+
1166
+ result = await self._adapter.execute_migration(
1167
+ item_id=item_id,
1168
+ from_store=from_store,
1169
+ to_store=to_store,
1170
+ metadata_store=metadata_store,
1171
+ )
1172
+
1173
+ # Record in history
1174
+ history_entry = TierMigrationHistoryModel(
1175
+ id=_generate_id(),
1176
+ item_id=item_id,
1177
+ from_tier_id=from_tier_id,
1178
+ to_tier_id=to_tier_id,
1179
+ size_bytes=result.size_bytes,
1180
+ started_at=result.started_at,
1181
+ completed_at=result.completed_at,
1182
+ status="completed" if result.success else "failed",
1183
+ error_message=result.error_message,
1184
+ )
1185
+ self._session.add(history_entry)
1186
+ await self._session.commit()
1187
+
1188
+ return result
1189
+
1190
+ # =========================================================================
1191
+ # Access Tracking
1192
+ # =========================================================================
1193
+
1194
+ async def record_access(self, item_id: str, tier_id: str) -> None:
1195
+ """Record an access to an item for intelligent tiering.
1196
+
1197
+ Args:
1198
+ item_id: Accessed item ID.
1199
+ tier_id: Current tier ID.
1200
+ """
1201
+ metadata_store = self._adapter.create_metadata_store()
1202
+
1203
+ info = metadata_store.get_info(item_id)
1204
+ if info:
1205
+ metadata_store.update_access(item_id)
1206
+ else:
1207
+ # Create new tracking entry
1208
+ info = TierInfo(
1209
+ item_id=item_id,
1210
+ tier_name=tier_id,
1211
+ created_at=datetime.utcnow(),
1212
+ access_count=1,
1213
+ last_accessed=datetime.utcnow(),
1214
+ )
1215
+ metadata_store.save_info(info)
1216
+
1217
+ # =========================================================================
1218
+ # Background Processing
1219
+ # =========================================================================
1220
+
1221
+ async def process_due_policies(self) -> list[TieringExecutionResult]:
1222
+ """Process all active policies that are due for execution.
1223
+
1224
+ Returns:
1225
+ List of execution results.
1226
+ """
1227
+ from truthound_dashboard.db.models import TierPolicyModel, TieringConfigModel
1228
+
1229
+ results = []
1230
+
1231
+ # Get active config
1232
+ config_result = await self._session.execute(
1233
+ select(TieringConfigModel).where(TieringConfigModel.is_active == True)
1234
+ )
1235
+ config = config_result.scalar_one_or_none()
1236
+
1237
+ batch_size = config.batch_size if config else 100
1238
+
1239
+ # Get active policies
1240
+ policies_result = await self._session.execute(
1241
+ select(TierPolicyModel)
1242
+ .where(TierPolicyModel.is_active == True)
1243
+ .where(TierPolicyModel.parent_id == None) # Only root policies
1244
+ .order_by(TierPolicyModel.priority)
1245
+ )
1246
+ policies = policies_result.scalars().all()
1247
+
1248
+ for policy in policies:
1249
+ try:
1250
+ result = await self.execute_policy(
1251
+ policy_id=policy.id,
1252
+ dry_run=False,
1253
+ batch_size=batch_size,
1254
+ )
1255
+ results.append(result)
1256
+ except Exception as e:
1257
+ logger.exception(f"Failed to execute policy {policy.id}: {e}")
1258
+ results.append(
1259
+ TieringExecutionResult(
1260
+ start_time=datetime.utcnow(),
1261
+ end_time=datetime.utcnow(),
1262
+ errors=[str(e)],
1263
+ )
1264
+ )
1265
+
1266
+ return results
1267
+
1268
+
1269
+ @dataclass
1270
+ class FallbackCompositePolicy:
1271
+ """Fallback composite policy implementation."""
1272
+
1273
+ from_tier: str
1274
+ to_tier: str
1275
+ policies: list[Any]
1276
+ require_all: bool = True
1277
+ direction: str = "demote"
1278
+
1279
+ def should_migrate(self, info: Any) -> bool:
1280
+ """Evaluate composite policy."""
1281
+ results = [p.should_migrate(info) for p in self.policies]
1282
+
1283
+ if self.require_all:
1284
+ return all(results)
1285
+ return any(results)
1286
+
1287
+ def evaluate(self, info: TierInfo) -> bool:
1288
+ """Alias for should_migrate."""
1289
+ return self.should_migrate(info)
1290
+
1291
+
1292
+ # =============================================================================
1293
+ # Scheduler Integration
1294
+ # =============================================================================
1295
+
1296
+
1297
+ async def process_tiering_policies(session: AsyncSession) -> list[TieringExecutionResult]:
1298
+ """Background task to process tiering policies.
1299
+
1300
+ This function is called by the scheduler to process all due policies.
1301
+
1302
+ Args:
1303
+ session: Database session.
1304
+
1305
+ Returns:
1306
+ List of execution results.
1307
+ """
1308
+ service = TieringService(session)
1309
+ return await service.process_due_policies()