familia 2.0.0.pre5 → 2.0.0.pre7

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 (151) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +57 -0
  3. data/.github/workflows/claude.yml +71 -0
  4. data/.gitignore +5 -1
  5. data/.rubocop.yml +3 -0
  6. data/CLAUDE.md +32 -10
  7. data/Gemfile +2 -2
  8. data/Gemfile.lock +4 -3
  9. data/docs/wiki/API-Reference.md +95 -18
  10. data/docs/wiki/Connection-Pooling-Guide.md +437 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
  12. data/docs/wiki/Expiration-Feature-Guide.md +596 -0
  13. data/docs/wiki/Feature-System-Guide.md +631 -0
  14. data/docs/wiki/Features-System-Developer-Guide.md +892 -0
  15. data/docs/wiki/Field-System-Guide.md +784 -0
  16. data/docs/wiki/Home.md +82 -15
  17. data/docs/wiki/Implementation-Guide.md +126 -33
  18. data/docs/wiki/Quantization-Feature-Guide.md +721 -0
  19. data/docs/wiki/Relationships-Guide.md +684 -0
  20. data/docs/wiki/Security-Model.md +65 -25
  21. data/docs/wiki/Transient-Fields-Guide.md +280 -0
  22. data/examples/bit_encoding_integration.rb +237 -0
  23. data/examples/redis_command_validation_example.rb +231 -0
  24. data/examples/relationships_basic.rb +273 -0
  25. data/lib/familia/base.rb +1 -1
  26. data/lib/familia/connection.rb +3 -3
  27. data/lib/familia/data_type/types/counter.rb +38 -0
  28. data/lib/familia/data_type/types/hashkey.rb +18 -0
  29. data/lib/familia/data_type/types/lock.rb +43 -0
  30. data/lib/familia/data_type/types/string.rb +9 -2
  31. data/lib/familia/data_type.rb +9 -6
  32. data/lib/familia/encryption/encrypted_data.rb +137 -0
  33. data/lib/familia/encryption/manager.rb +21 -4
  34. data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
  35. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
  36. data/lib/familia/encryption.rb +1 -1
  37. data/lib/familia/errors.rb +17 -3
  38. data/lib/familia/features/encrypted_fields/concealed_string.rb +293 -0
  39. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
  40. data/lib/familia/features/encrypted_fields.rb +413 -4
  41. data/lib/familia/features/expiration.rb +319 -33
  42. data/lib/familia/features/quantization.rb +385 -44
  43. data/lib/familia/features/relationships/cascading.rb +438 -0
  44. data/lib/familia/features/relationships/indexing.rb +370 -0
  45. data/lib/familia/features/relationships/membership.rb +503 -0
  46. data/lib/familia/features/relationships/permission_management.rb +264 -0
  47. data/lib/familia/features/relationships/querying.rb +620 -0
  48. data/lib/familia/features/relationships/redis_operations.rb +274 -0
  49. data/lib/familia/features/relationships/score_encoding.rb +442 -0
  50. data/lib/familia/features/relationships/tracking.rb +379 -0
  51. data/lib/familia/features/relationships.rb +466 -0
  52. data/lib/familia/features/safe_dump.rb +1 -1
  53. data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
  54. data/lib/familia/features/transient_fields.rb +192 -10
  55. data/lib/familia/features.rb +2 -1
  56. data/lib/familia/field_type.rb +5 -2
  57. data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
  58. data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
  59. data/lib/familia/horreum/core/serialization.rb +535 -0
  60. data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
  61. data/lib/familia/horreum/core.rb +21 -0
  62. data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
  63. data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +45 -29
  64. data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
  65. data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
  66. data/lib/familia/horreum.rb +17 -17
  67. data/lib/familia/validation/command_recorder.rb +336 -0
  68. data/lib/familia/validation/expectations.rb +519 -0
  69. data/lib/familia/validation/test_helpers.rb +443 -0
  70. data/lib/familia/validation/validator.rb +412 -0
  71. data/lib/familia/validation.rb +140 -0
  72. data/lib/familia/version.rb +1 -1
  73. data/lib/familia.rb +1 -1
  74. data/try/core/create_method_try.rb +240 -0
  75. data/try/core/database_consistency_try.rb +299 -0
  76. data/try/core/errors_try.rb +25 -4
  77. data/try/core/familia_try.rb +1 -1
  78. data/try/core/persistence_operations_try.rb +297 -0
  79. data/try/data_types/counter_try.rb +93 -0
  80. data/try/data_types/lock_try.rb +133 -0
  81. data/try/debugging/debug_aad_process.rb +82 -0
  82. data/try/debugging/debug_concealed_internal.rb +59 -0
  83. data/try/debugging/debug_concealed_reveal.rb +61 -0
  84. data/try/debugging/debug_context_aad.rb +68 -0
  85. data/try/debugging/debug_context_simple.rb +80 -0
  86. data/try/debugging/debug_cross_context.rb +62 -0
  87. data/try/debugging/debug_database_load.rb +64 -0
  88. data/try/debugging/debug_encrypted_json_check.rb +53 -0
  89. data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
  90. data/try/debugging/debug_exists_lifecycle.rb +54 -0
  91. data/try/debugging/debug_field_decrypt.rb +74 -0
  92. data/try/debugging/debug_fresh_cross_context.rb +73 -0
  93. data/try/debugging/debug_load_path.rb +66 -0
  94. data/try/debugging/debug_method_definition.rb +46 -0
  95. data/try/debugging/debug_method_resolution.rb +41 -0
  96. data/try/debugging/debug_minimal.rb +24 -0
  97. data/try/debugging/debug_provider.rb +68 -0
  98. data/try/debugging/debug_secure_behavior.rb +73 -0
  99. data/try/debugging/debug_string_class.rb +46 -0
  100. data/try/debugging/debug_test.rb +46 -0
  101. data/try/debugging/debug_test_design.rb +80 -0
  102. data/try/edge_cases/hash_symbolization_try.rb +1 -0
  103. data/try/edge_cases/reserved_keywords_try.rb +1 -0
  104. data/try/edge_cases/string_coercion_try.rb +2 -0
  105. data/try/encryption/encryption_core_try.rb +6 -4
  106. data/try/features/categorical_permissions_try.rb +515 -0
  107. data/try/features/encrypted_fields_core_try.rb +19 -11
  108. data/try/features/encrypted_fields_integration_try.rb +66 -70
  109. data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
  110. data/try/features/encrypted_fields_security_try.rb +151 -144
  111. data/try/features/encryption_fields/aad_protection_try.rb +108 -23
  112. data/try/features/encryption_fields/concealed_string_core_try.rb +253 -0
  113. data/try/features/encryption_fields/context_isolation_try.rb +30 -8
  114. data/try/features/encryption_fields/error_conditions_try.rb +6 -6
  115. data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
  116. data/try/features/encryption_fields/fresh_key_try.rb +27 -22
  117. data/try/features/encryption_fields/key_rotation_try.rb +16 -10
  118. data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
  119. data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
  120. data/try/features/encryption_fields/thread_safety_try.rb +6 -6
  121. data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
  122. data/try/features/feature_dependencies_try.rb +3 -3
  123. data/try/features/relationships_edge_cases_try.rb +145 -0
  124. data/try/features/relationships_performance_minimal_try.rb +132 -0
  125. data/try/features/relationships_performance_simple_try.rb +155 -0
  126. data/try/features/relationships_performance_try.rb +420 -0
  127. data/try/features/relationships_performance_working_try.rb +144 -0
  128. data/try/features/relationships_try.rb +237 -0
  129. data/try/features/safe_dump_try.rb +3 -0
  130. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  131. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  132. data/try/features/transient_fields_core_try.rb +1 -1
  133. data/try/features/transient_fields_integration_try.rb +1 -1
  134. data/try/helpers/test_helpers.rb +26 -1
  135. data/try/horreum/base_try.rb +14 -8
  136. data/try/horreum/enhanced_conflict_handling_try.rb +3 -1
  137. data/try/horreum/initialization_try.rb +1 -1
  138. data/try/horreum/relations_try.rb +2 -2
  139. data/try/horreum/serialization_persistent_fields_try.rb +8 -8
  140. data/try/horreum/serialization_try.rb +39 -4
  141. data/try/models/customer_safe_dump_try.rb +1 -1
  142. data/try/models/customer_try.rb +1 -1
  143. data/try/validation/atomic_operations_try.rb.disabled +320 -0
  144. data/try/validation/command_validation_try.rb.disabled +207 -0
  145. data/try/validation/performance_validation_try.rb.disabled +324 -0
  146. data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
  147. metadata +81 -12
  148. data/TEST_COVERAGE.md +0 -40
  149. data/lib/familia/features/relatable_objects.rb +0 -125
  150. data/lib/familia/horreum/serialization.rb +0 -473
  151. data/try/features/relatable_objects_try.rb +0 -220
@@ -0,0 +1,721 @@
1
+ # Quantization Feature Guide
2
+
3
+ ## Overview
4
+
5
+ The Quantization feature provides time-based data bucketing capabilities for Familia objects. It allows you to round timestamps to specific intervals (quantums) and format them for consistent time-based data organization, analytics, and caching strategies.
6
+
7
+ ## Core Concepts
8
+
9
+ ### Quantum Intervals
10
+
11
+ A **quantum** is a time interval used to bucket timestamps. Common quantums include:
12
+
13
+ - **Minutes**: `1.minute`, `5.minutes`, `15.minutes`
14
+ - **Hours**: `1.hour`, `6.hours`, `12.hours`
15
+ - **Days**: `1.day`, `7.days`
16
+ - **Custom**: Any number of seconds (e.g., `90` for 1.5 minutes)
17
+
18
+ ### Quantized Timestamps (qstamp)
19
+
20
+ The `qstamp` method generates quantized timestamps by:
21
+ 1. Taking the current time (or specified time)
22
+ 2. Rounding down to the nearest quantum boundary
23
+ 3. Returning either a Unix timestamp (Integer) or formatted string
24
+
25
+ ### Time Bucketing
26
+
27
+ Quantization enables consistent data bucketing across time periods:
28
+
29
+ ```ruby
30
+ # All timestamps between 14:00:00 and 14:59:59 become 14:00:00
31
+ qstamp(1.hour, pattern: '%H:%M:%S', time: Time.parse('14:30:45')) # => "14:00:00"
32
+ qstamp(1.hour, pattern: '%H:%M:%S', time: Time.parse('14:05:12')) # => "14:00:00"
33
+ qstamp(1.hour, pattern: '%H:%M:%S', time: Time.parse('14:55:33')) # => "14:00:00"
34
+ ```
35
+
36
+ ## Basic Usage
37
+
38
+ ### Enabling Quantization
39
+
40
+ ```ruby
41
+ class AnalyticsEvent < Familia::Horreum
42
+ feature :quantization
43
+ default_expiration 300 # 5 minutes (used as default quantum)
44
+
45
+ identifier_field :event_id
46
+ field :event_id, :event_type, :user_id, :data, :timestamp
47
+ end
48
+ ```
49
+
50
+ ### Simple Quantized Timestamps
51
+
52
+ ```ruby
53
+ event = AnalyticsEvent.new
54
+
55
+ # Using default quantum (from default_expiration: 300 seconds)
56
+ timestamp = event.qstamp
57
+ # => 1687276800 (Unix timestamp rounded to 5-minute boundary)
58
+
59
+ # Using custom quantum
60
+ hourly_timestamp = event.qstamp(1.hour)
61
+ # => 1687276800 (rounded to hour boundary)
62
+
63
+ # Class-level qstamp (same functionality)
64
+ AnalyticsEvent.qstamp(1.hour)
65
+ # => 1687276800
66
+ ```
67
+
68
+ ### Formatted Timestamps
69
+
70
+ ```ruby
71
+ # Generate formatted timestamp strings
72
+ hourly_key = AnalyticsEvent.qstamp(1.hour, pattern: '%Y%m%d%H')
73
+ # => "2023061514" (YYYYMMDDHH format)
74
+
75
+ daily_key = AnalyticsEvent.qstamp(1.day, pattern: '%Y-%m-%d')
76
+ # => "2023-06-15" (ISO date format)
77
+
78
+ weekly_key = AnalyticsEvent.qstamp(1.week, pattern: '%Y-W%U')
79
+ # => "2023-W24" (Year-Week format)
80
+ ```
81
+
82
+ ### Specifying Custom Time
83
+
84
+ ```ruby
85
+ # Quantize a specific timestamp
86
+ specific_time = Time.utc(2023, 6, 15, 14, 30, 45)
87
+
88
+ quantized = AnalyticsEvent.qstamp(
89
+ 1.hour,
90
+ pattern: '%Y-%m-%d %H:00:00',
91
+ time: specific_time
92
+ )
93
+ # => "2023-06-15 14:00:00"
94
+ ```
95
+
96
+ ## Advanced Usage Patterns
97
+
98
+ ### Time-Based Cache Keys
99
+
100
+ ```ruby
101
+ class MetricsCache < Familia::Horreum
102
+ feature :quantization
103
+
104
+ identifier_field :cache_key
105
+ field :cache_key, :data, :computed_at
106
+ hashkey :hourly_metrics
107
+
108
+ def self.hourly_cache_key(metric_type, time = nil)
109
+ timestamp = qstamp(1.hour, pattern: '%Y%m%d%H', time: time)
110
+ "metrics:#{metric_type}:#{timestamp}"
111
+ end
112
+
113
+ def self.daily_cache_key(metric_type, time = nil)
114
+ timestamp = qstamp(1.day, pattern: '%Y%m%d', time: time)
115
+ "metrics:#{metric_type}:daily:#{timestamp}"
116
+ end
117
+ end
118
+
119
+ # Usage
120
+ hourly_key = MetricsCache.hourly_cache_key('page_views')
121
+ # => "metrics:page_views:2023061514"
122
+
123
+ daily_key = MetricsCache.daily_cache_key('signups')
124
+ # => "metrics:signups:daily:20230615"
125
+ ```
126
+
127
+ ### Analytics Data Bucketing
128
+
129
+ ```ruby
130
+ class UserActivity < Familia::Horreum
131
+ feature :quantization
132
+
133
+ identifier_field :bucket_id
134
+ field :bucket_id, :user_count, :event_count, :bucket_time
135
+
136
+ def self.record_activity(user_id, event_type)
137
+ # Create 15-minute activity buckets
138
+ bucket_key = qstamp(15.minutes, pattern: '%Y%m%d%H%M')
139
+ bucket_id = "activity:#{bucket_key}"
140
+
141
+ # Find or create bucket
142
+ bucket = find(bucket_id) || new(bucket_id: bucket_id, bucket_time: bucket_key)
143
+
144
+ # Update metrics
145
+ bucket.user_count ||= 0
146
+ bucket.event_count ||= 0
147
+
148
+ # Use Redis sets/hashes for precise counting
149
+ bucket_users = bucket.related_set("users")
150
+ bucket_users.add(user_id)
151
+
152
+ bucket.user_count = bucket_users.count
153
+ bucket.event_count += 1
154
+
155
+ bucket.save
156
+ end
157
+
158
+ def self.activity_for_hour(hour_time)
159
+ # Get all 15-minute buckets for the hour
160
+ hour_start = qstamp(1.hour, time: hour_time)
161
+ hour_pattern = Time.at(hour_start).strftime('%Y%m%d%H')
162
+
163
+ # Find buckets matching the hour pattern
164
+ bucket_keys = (0..3).map do |quarter|
165
+ minute = quarter * 15
166
+ Time.at(hour_start + (minute * 60)).strftime('%Y%m%d%H%M')
167
+ end
168
+
169
+ bucket_keys.map { |key| find("activity:#{key}") }.compact
170
+ end
171
+ end
172
+
173
+ # Usage
174
+ UserActivity.record_activity('user_123', 'page_view')
175
+ hourly_buckets = UserActivity.activity_for_hour(Time.now)
176
+ ```
177
+
178
+ ### Time-Series Data Storage
179
+
180
+ ```ruby
181
+ class TimeSeriesMetric < Familia::Horreum
182
+ feature :quantization
183
+
184
+ identifier_field :series_key
185
+ field :series_key, :metric_name, :interval, :value, :timestamp
186
+ zset :data_points # Sorted set for time-ordered data
187
+
188
+ def self.record_metric(metric_name, value, interval = 1.minute, time = nil)
189
+ # Create consistent time bucket
190
+ bucket_timestamp = qstamp(interval, time: time)
191
+ series_key = "#{metric_name}:#{interval}"
192
+
193
+ # Store in sorted set with timestamp as score
194
+ metric = find(series_key) || new(
195
+ series_key: series_key,
196
+ metric_name: metric_name,
197
+ interval: interval
198
+ )
199
+
200
+ metric.data_points.add(bucket_timestamp, value)
201
+ metric.save
202
+
203
+ metric
204
+ end
205
+
206
+ def self.get_series(metric_name, interval, start_time, end_time)
207
+ series_key = "#{metric_name}:#{interval}"
208
+ metric = find(series_key)
209
+ return [] unless metric
210
+
211
+ start_bucket = qstamp(interval, time: start_time)
212
+ end_bucket = qstamp(interval, time: end_time)
213
+
214
+ metric.data_points.rangebyscore(start_bucket, end_bucket, with_scores: true)
215
+ end
216
+ end
217
+
218
+ # Usage - Record CPU usage every minute
219
+ TimeSeriesMetric.record_metric('cpu_usage', 75.5, 1.minute)
220
+ TimeSeriesMetric.record_metric('cpu_usage', 82.1, 1.minute, Time.now + 1.minute)
221
+
222
+ # Retrieve data for last hour
223
+ series_data = TimeSeriesMetric.get_series(
224
+ 'cpu_usage',
225
+ 1.minute,
226
+ Time.now - 1.hour,
227
+ Time.now
228
+ )
229
+ ```
230
+
231
+ ### Log Aggregation by Time
232
+
233
+ ```ruby
234
+ class LogAggregator < Familia::Horreum
235
+ feature :quantization
236
+
237
+ identifier_field :log_bucket
238
+ field :log_bucket, :level, :count, :first_seen, :last_seen
239
+ hashkey :message_samples # Store sample messages
240
+
241
+ def self.aggregate_log(level, message, time = nil)
242
+ # Create 5-minute buckets for log aggregation
243
+ bucket_time = qstamp(5.minutes, time: time)
244
+ bucket_id = "logs:#{level}:#{bucket_time}"
245
+
246
+ aggregator = find(bucket_id) || new(
247
+ log_bucket: bucket_id,
248
+ level: level,
249
+ count: 0,
250
+ first_seen: bucket_time,
251
+ last_seen: bucket_time
252
+ )
253
+
254
+ aggregator.count += 1
255
+ aggregator.last_seen = Time.now.to_i
256
+
257
+ # Keep sample messages (up to 10)
258
+ sample_key = "sample_#{aggregator.count}"
259
+ if aggregator.message_samples.count < 10
260
+ aggregator.message_samples.hset(sample_key, message.truncate(200))
261
+ end
262
+
263
+ aggregator.save
264
+ aggregator
265
+ end
266
+
267
+ def self.error_summary(time_range = 1.hour)
268
+ start_time = Time.now - time_range
269
+ end_time = Time.now
270
+
271
+ # Find all error buckets in time range
272
+ buckets = []
273
+ current = qstamp(5.minutes, time: start_time)
274
+ final = qstamp(5.minutes, time: end_time)
275
+
276
+ while current <= final
277
+ bucket_time = Time.at(current).strftime('%Y%m%d%H%M')
278
+ error_bucket = find("logs:error:#{current}")
279
+ buckets << error_bucket if error_bucket
280
+
281
+ current += 5.minutes
282
+ end
283
+
284
+ {
285
+ total_errors: buckets.sum(&:count),
286
+ buckets: buckets,
287
+ peak_bucket: buckets.max_by(&:count)
288
+ }
289
+ end
290
+ end
291
+ ```
292
+
293
+ ## Integration Patterns
294
+
295
+ ### Rails Integration
296
+
297
+ ```ruby
298
+ # config/initializers/quantization.rb
299
+ class ApplicationMetrics
300
+ include Familia::Horreum
301
+ feature :quantization
302
+
303
+ # Set up different quantum intervals for different metrics
304
+ QUANTUM_CONFIGS = {
305
+ real_time: 1.minute, # High frequency metrics
306
+ standard: 5.minutes, # Regular analytics
307
+ reporting: 1.hour, # Hourly reports
308
+ archival: 1.day # Daily summaries
309
+ }.freeze
310
+
311
+ def self.metric_key(name, quantum_type = :standard, time = nil)
312
+ quantum = QUANTUM_CONFIGS[quantum_type]
313
+ timestamp = qstamp(quantum, pattern: '%Y%m%d%H%M', time: time)
314
+ "metrics:#{name}:#{quantum_type}:#{timestamp}"
315
+ end
316
+ end
317
+
318
+ # In your controllers/models
319
+ class MetricsCollector
320
+ def self.track_page_view(page, user_id = nil)
321
+ # Track at multiple granularities
322
+ [:real_time, :standard, :reporting].each do |quantum_type|
323
+ key = ApplicationMetrics.metric_key("page_views:#{page}", quantum_type)
324
+
325
+ # Increment counter
326
+ Familia.dbclient.incr(key)
327
+
328
+ # Set expiration based on quantum type
329
+ ttl = case quantum_type
330
+ when :real_time then 2.hours
331
+ when :standard then 1.day
332
+ when :reporting then 1.week
333
+ end
334
+ Familia.dbclient.expire(key, ttl)
335
+ end
336
+ end
337
+ end
338
+ ```
339
+
340
+ ### Background Job Integration
341
+
342
+ ```ruby
343
+ class QuantizedDataProcessor
344
+ include Sidekiq::Worker
345
+
346
+ # Process data in quantized buckets every 5 minutes
347
+ sidekiq_cron '*/5 * * * *'
348
+
349
+ def perform
350
+ # Process current 5-minute bucket
351
+ current_bucket = AnalyticsEvent.qstamp(5.minutes)
352
+ process_bucket(current_bucket)
353
+
354
+ # Also process previous bucket in case of delayed data
355
+ previous_bucket = AnalyticsEvent.qstamp(5.minutes, time: Time.now - 5.minutes)
356
+ process_bucket(previous_bucket)
357
+ end
358
+
359
+ private
360
+
361
+ def process_bucket(bucket_timestamp)
362
+ bucket_key = Time.at(bucket_timestamp).strftime('%Y%m%d%H%M')
363
+
364
+ # Find all events in this bucket
365
+ events = AnalyticsEvent.all.select do |event|
366
+ event_bucket = AnalyticsEvent.qstamp(5.minutes, time: Time.at(event.timestamp))
367
+ event_bucket == bucket_timestamp
368
+ end
369
+
370
+ # Aggregate and store results
371
+ aggregated = aggregate_events(events)
372
+ store_aggregated_data(bucket_key, aggregated)
373
+ end
374
+ end
375
+ ```
376
+
377
+ ### API Response Caching
378
+
379
+ ```ruby
380
+ class CachedApiResponse < Familia::Horreum
381
+ feature :quantization
382
+ feature :expiration
383
+
384
+ identifier_field :cache_key
385
+ field :cache_key, :endpoint, :params_hash, :response_data
386
+ default_expiration 15.minutes
387
+
388
+ def self.cached_response(endpoint, params, cache_duration = 5.minutes)
389
+ # Create cache key with quantized timestamp
390
+ params_key = Digest::SHA256.hexdigest(params.to_json)
391
+ timestamp = qstamp(cache_duration, pattern: '%Y%m%d%H%M')
392
+ cache_key = "api:#{endpoint}:#{params_key}:#{timestamp}"
393
+
394
+ # Try to find existing cache
395
+ cached = find(cache_key)
396
+ return JSON.parse(cached.response_data) if cached
397
+
398
+ # Generate new response
399
+ response_data = yield # Block provides fresh data
400
+
401
+ # Cache the response
402
+ new_cache = new(
403
+ cache_key: cache_key,
404
+ endpoint: endpoint,
405
+ params_hash: params_key,
406
+ response_data: response_data.to_json
407
+ )
408
+ new_cache.save
409
+ new_cache.update_expiration
410
+
411
+ response_data
412
+ end
413
+ end
414
+
415
+ # Usage in controller
416
+ class MetricsController < ApplicationController
417
+ def dashboard_stats
418
+ stats = CachedApiResponse.cached_response('/dashboard/stats', params, 1.minute) do
419
+ # This block only runs if cache miss
420
+ {
421
+ active_users: User.active.count,
422
+ total_orders: Order.today.count,
423
+ revenue: Order.today.sum(:total)
424
+ }
425
+ end
426
+
427
+ render json: stats
428
+ end
429
+ end
430
+ ```
431
+
432
+ ## Quantum Calculation Examples
433
+
434
+ ### Understanding Quantum Boundaries
435
+
436
+ ```ruby
437
+ # Example with 1-hour quantum
438
+ time1 = Time.utc(2023, 6, 15, 14, 15, 30) # 14:15:30
439
+ time2 = Time.utc(2023, 6, 15, 14, 45, 12) # 14:45:12
440
+
441
+ hour_stamp1 = AnalyticsEvent.qstamp(1.hour, time: time1)
442
+ hour_stamp2 = AnalyticsEvent.qstamp(1.hour, time: time2)
443
+
444
+ # Both timestamps round down to 14:00:00
445
+ Time.at(hour_stamp1).strftime('%H:%M:%S') # => "14:00:00"
446
+ Time.at(hour_stamp2).strftime('%H:%M:%S') # => "14:00:00"
447
+ hour_stamp1 == hour_stamp2 # => true
448
+
449
+ # Example with 15-minute quantum
450
+ quarter1 = AnalyticsEvent.qstamp(15.minutes, time: time1) # 14:15:30 -> 14:15:00
451
+ quarter2 = AnalyticsEvent.qstamp(15.minutes, time: time2) # 14:45:12 -> 14:45:00
452
+
453
+ Time.at(quarter1).strftime('%H:%M:%S') # => "14:15:00"
454
+ Time.at(quarter2).strftime('%H:%M:%S') # => "14:45:00"
455
+ quarter1 == quarter2 # => false (different 15-minute buckets)
456
+ ```
457
+
458
+ ### Cross-Timezone Quantization
459
+
460
+ ```ruby
461
+ class GlobalMetrics < Familia::Horreum
462
+ feature :quantization
463
+
464
+ def self.utc_hourly_key(time = nil)
465
+ # Always quantize in UTC for global consistency
466
+ utc_time = time&.utc || Time.now.utc
467
+ qstamp(1.hour, pattern: '%Y%m%d%H', time: utc_time)
468
+ end
469
+
470
+ def self.local_daily_key(timezone, time = nil)
471
+ # Quantize in local timezone for regional reports
472
+ local_time = time || Time.now
473
+ local_time = local_time.in_time_zone(timezone) if local_time.respond_to?(:in_time_zone)
474
+ qstamp(1.day, pattern: '%Y%m%d', time: local_time)
475
+ end
476
+ end
477
+
478
+ # Usage
479
+ utc_key = GlobalMetrics.utc_hourly_key # Always consistent globally
480
+ ny_key = GlobalMetrics.local_daily_key('America/New_York')
481
+ tokyo_key = GlobalMetrics.local_daily_key('Asia/Tokyo')
482
+ ```
483
+
484
+ ## Performance Optimization
485
+
486
+ ### Efficient Bucket Operations
487
+
488
+ ```ruby
489
+ class OptimizedQuantization < Familia::Horreum
490
+ feature :quantization
491
+
492
+ # Cache quantum calculations
493
+ def self.cached_qstamp(quantum, pattern: nil, time: nil)
494
+ cache_key = "qstamp:#{quantum}:#{pattern}:#{time&.to_i}"
495
+
496
+ Rails.cache.fetch(cache_key, expires_in: quantum) do
497
+ qstamp(quantum, pattern: pattern, time: time)
498
+ end
499
+ end
500
+
501
+ # Batch process multiple timestamps
502
+ def self.batch_quantize(timestamps, quantum, pattern: nil)
503
+ timestamps.map do |ts|
504
+ qstamp(quantum, pattern: pattern, time: ts)
505
+ end.uniq # Remove duplicates from same bucket
506
+ end
507
+
508
+ # Pre-generate common buckets
509
+ def self.pregenerate_buckets(quantum, count = 24)
510
+ base_time = qstamp(quantum) # Current bucket
511
+
512
+ (0...count).map do |offset|
513
+ bucket_time = base_time + (offset * quantum)
514
+ Time.at(bucket_time).strftime('%Y%m%d%H%M')
515
+ end
516
+ end
517
+ end
518
+ ```
519
+
520
+ ### Memory-Efficient Storage
521
+
522
+ ```ruby
523
+ class CompactTimeSeriesStorage < Familia::Horreum
524
+ feature :quantization
525
+
526
+ identifier_field :series_id
527
+ field :series_id, :metric_name, :quantum
528
+
529
+ # Store quantized data in Redis sorted sets for efficiency
530
+ def record_value(value, time = nil)
531
+ bucket_timestamp = self.class.qstamp(quantum, time: time)
532
+
533
+ # Use timestamp as score, value as member
534
+ data_key = "#{series_id}:data"
535
+ Familia.dbclient.zadd(data_key, bucket_timestamp, value)
536
+
537
+ # Set TTL based on quantum (longer quantum = longer retention)
538
+ ttl = calculate_retention_period
539
+ Familia.dbclient.expire(data_key, ttl)
540
+ end
541
+
542
+ def get_range(start_time, end_time)
543
+ start_bucket = self.class.qstamp(quantum, time: start_time)
544
+ end_bucket = self.class.qstamp(quantum, time: end_time)
545
+
546
+ data_key = "#{series_id}:data"
547
+ Familia.dbclient.zrangebyscore(data_key, start_bucket, end_bucket, with_scores: true)
548
+ end
549
+
550
+ private
551
+
552
+ def calculate_retention_period
553
+ case quantum
554
+ when 0..300 then 1.day # Up to 5 minutes: keep 1 day
555
+ when 301..3600 then 1.week # Up to 1 hour: keep 1 week
556
+ when 3601..86400 then 1.month # Up to 1 day: keep 1 month
557
+ else 1.year # Longer: keep 1 year
558
+ end
559
+ end
560
+ end
561
+ ```
562
+
563
+ ## Testing Quantization
564
+
565
+ ### RSpec Testing
566
+
567
+ ```ruby
568
+ RSpec.describe AnalyticsEvent do
569
+ describe "quantization behavior" do
570
+ let(:test_time) { Time.utc(2023, 6, 15, 14, 30, 45) }
571
+
572
+ it "quantizes to hour boundaries" do
573
+ stamp = described_class.qstamp(1.hour, time: test_time)
574
+ quantized_time = Time.at(stamp)
575
+
576
+ expect(quantized_time.hour).to eq(14)
577
+ expect(quantized_time.min).to eq(0)
578
+ expect(quantized_time.sec).to eq(0)
579
+ end
580
+
581
+ it "generates consistent buckets for same period" do
582
+ time1 = Time.utc(2023, 6, 15, 14, 10, 0)
583
+ time2 = Time.utc(2023, 6, 15, 14, 50, 0)
584
+
585
+ stamp1 = described_class.qstamp(1.hour, time: time1)
586
+ stamp2 = described_class.qstamp(1.hour, time: time2)
587
+
588
+ expect(stamp1).to eq(stamp2)
589
+ end
590
+
591
+ it "formats timestamps correctly" do
592
+ formatted = described_class.qstamp(
593
+ 1.hour,
594
+ pattern: '%Y-%m-%d %H:00',
595
+ time: test_time
596
+ )
597
+
598
+ expect(formatted).to eq('2023-06-15 14:00')
599
+ end
600
+
601
+ it "uses default quantum from default_expiration" do
602
+ allow(described_class).to receive(:default_expiration).and_return(300)
603
+
604
+ stamp = described_class.qstamp
605
+
606
+ # Should use 5-minute quantum (300 seconds)
607
+ expect(stamp % 300).to eq(0)
608
+ end
609
+ end
610
+ end
611
+ ```
612
+
613
+ ### Integration Testing
614
+
615
+ ```ruby
616
+ # Feature test for time-based caching
617
+ RSpec.feature "Quantized API Caching" do
618
+ scenario "responses are cached within quantum boundaries" do
619
+ travel_to Time.utc(2023, 6, 15, 14, 22, 30) do
620
+ # First request
621
+ get '/api/stats'
622
+ first_response = JSON.parse(response.body)
623
+ first_cache_key = extract_cache_key_from_headers(response)
624
+
625
+ travel 2.minutes # Still in same 5-minute bucket
626
+
627
+ # Second request should use cache
628
+ get '/api/stats'
629
+ second_response = JSON.parse(response.body)
630
+ second_cache_key = extract_cache_key_from_headers(response)
631
+
632
+ expect(first_response).to eq(second_response)
633
+ expect(first_cache_key).to eq(second_cache_key)
634
+
635
+ travel 4.minutes # Now in next 5-minute bucket
636
+
637
+ # Third request should have new cache
638
+ get '/api/stats'
639
+ third_cache_key = extract_cache_key_from_headers(response)
640
+
641
+ expect(third_cache_key).not_to eq(first_cache_key)
642
+ end
643
+ end
644
+ end
645
+ ```
646
+
647
+ ## Best Practices
648
+
649
+ ### 1. Choose Appropriate Quantums
650
+
651
+ ```ruby
652
+ # Match quantum to data characteristics
653
+ class MetricsConfig
654
+ QUANTUM_RECOMMENDATIONS = {
655
+ real_time_alerts: 1.minute, # High frequency, short retention
656
+ user_analytics: 5.minutes, # Medium frequency, medium retention
657
+ business_reports: 1.hour, # Low frequency, long retention
658
+ daily_summaries: 1.day, # Summary data, permanent retention
659
+ log_aggregation: 10.minutes # Balance detail vs. performance
660
+ }.freeze
661
+
662
+ def self.quantum_for(metric_type)
663
+ QUANTUM_RECOMMENDATIONS[metric_type] || 5.minutes
664
+ end
665
+ end
666
+ ```
667
+
668
+ ### 2. Handle Edge Cases
669
+
670
+ ```ruby
671
+ class RobustQuantization < Familia::Horreum
672
+ feature :quantization
673
+
674
+ def self.safe_qstamp(quantum, pattern: nil, time: nil)
675
+ # Validate quantum
676
+ quantum = quantum.to_f
677
+ raise ArgumentError, "Quantum must be positive" unless quantum.positive?
678
+
679
+ # Handle edge cases
680
+ time ||= Familia.now
681
+ time = Time.at(time) if time.is_a?(Numeric)
682
+
683
+ # Generate timestamp
684
+ qstamp(quantum, pattern: pattern, time: time)
685
+ rescue => e
686
+ # Fallback to current time with default quantum
687
+ Rails.logger.warn "Quantization failed: #{e.message}"
688
+ qstamp(300) # 5-minute fallback
689
+ end
690
+ end
691
+ ```
692
+
693
+ ### 3. Monitor Bucket Distribution
694
+
695
+ ```ruby
696
+ class QuantizationMonitor
697
+ def self.analyze_bucket_distribution(metric_name, quantum, time_range = 24.hours)
698
+ buckets = {}
699
+ current_time = Time.now - time_range
700
+ end_time = Time.now
701
+
702
+ while current_time <= end_time
703
+ bucket = AnalyticsEvent.qstamp(quantum, time: current_time)
704
+ buckets[bucket] ||= 0
705
+ buckets[bucket] += 1 # Count events in this bucket
706
+
707
+ current_time += quantum
708
+ end
709
+
710
+ {
711
+ total_buckets: buckets.size,
712
+ avg_events_per_bucket: buckets.values.sum.to_f / buckets.size,
713
+ max_events_bucket: buckets.values.max,
714
+ min_events_bucket: buckets.values.min,
715
+ distribution: buckets
716
+ }
717
+ end
718
+ end
719
+ ```
720
+
721
+ The Quantization feature provides powerful time-based data organization capabilities, enabling efficient analytics, caching, and time-series data management in Familia applications.