familia 2.0.0.pre5 → 2.0.0.pre6
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.
- checksums.yaml +4 -4
- data/CLAUDE.md +8 -5
- data/Gemfile +1 -1
- data/Gemfile.lock +4 -3
- data/docs/wiki/API-Reference.md +95 -18
- data/docs/wiki/Connection-Pooling-Guide.md +437 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
- data/docs/wiki/Expiration-Feature-Guide.md +596 -0
- data/docs/wiki/Feature-System-Guide.md +600 -0
- data/docs/wiki/Features-System-Developer-Guide.md +892 -0
- data/docs/wiki/Field-System-Guide.md +784 -0
- data/docs/wiki/Home.md +72 -15
- data/docs/wiki/Implementation-Guide.md +126 -33
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/RelatableObjects-Guide.md +563 -0
- data/docs/wiki/Security-Model.md +65 -25
- data/docs/wiki/Transient-Fields-Guide.md +280 -0
- data/lib/familia/base.rb +1 -1
- data/lib/familia/data_type/types/counter.rb +38 -0
- data/lib/familia/data_type/types/hashkey.rb +18 -0
- data/lib/familia/data_type/types/lock.rb +43 -0
- data/lib/familia/data_type/types/string.rb +9 -2
- data/lib/familia/data_type.rb +2 -2
- data/lib/familia/encryption/encrypted_data.rb +137 -0
- data/lib/familia/encryption/manager.rb +21 -4
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
- data/lib/familia/encryption.rb +1 -1
- data/lib/familia/errors.rb +17 -3
- data/lib/familia/features/encrypted_fields/concealed_string.rb +295 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
- data/lib/familia/features/expiration.rb +1 -1
- data/lib/familia/features/quantization.rb +1 -1
- data/lib/familia/features/safe_dump.rb +1 -1
- data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
- data/lib/familia/features/transient_fields.rb +1 -1
- data/lib/familia/field_type.rb +5 -2
- data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
- data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
- data/lib/familia/horreum/core/serialization.rb +535 -0
- data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
- data/lib/familia/horreum/core.rb +21 -0
- data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
- data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +44 -28
- data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
- data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
- data/lib/familia/horreum.rb +17 -17
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -1
- data/try/core/create_method_try.rb +240 -0
- data/try/core/database_consistency_try.rb +299 -0
- data/try/core/errors_try.rb +25 -4
- data/try/core/familia_try.rb +1 -1
- data/try/core/persistence_operations_try.rb +297 -0
- data/try/data_types/counter_try.rb +93 -0
- data/try/data_types/lock_try.rb +133 -0
- data/try/debugging/debug_aad_process.rb +82 -0
- data/try/debugging/debug_concealed_internal.rb +59 -0
- data/try/debugging/debug_concealed_reveal.rb +61 -0
- data/try/debugging/debug_context_aad.rb +68 -0
- data/try/debugging/debug_context_simple.rb +80 -0
- data/try/debugging/debug_cross_context.rb +62 -0
- data/try/debugging/debug_database_load.rb +64 -0
- data/try/debugging/debug_encrypted_json_check.rb +53 -0
- data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
- data/try/debugging/debug_exists_lifecycle.rb +54 -0
- data/try/debugging/debug_field_decrypt.rb +74 -0
- data/try/debugging/debug_fresh_cross_context.rb +73 -0
- data/try/debugging/debug_load_path.rb +66 -0
- data/try/debugging/debug_method_definition.rb +46 -0
- data/try/debugging/debug_method_resolution.rb +41 -0
- data/try/debugging/debug_minimal.rb +24 -0
- data/try/debugging/debug_provider.rb +68 -0
- data/try/debugging/debug_secure_behavior.rb +73 -0
- data/try/debugging/debug_string_class.rb +46 -0
- data/try/debugging/debug_test.rb +46 -0
- data/try/debugging/debug_test_design.rb +80 -0
- data/try/encryption/encryption_core_try.rb +3 -3
- data/try/features/encrypted_fields_core_try.rb +19 -11
- data/try/features/encrypted_fields_integration_try.rb +66 -70
- data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
- data/try/features/encrypted_fields_security_try.rb +151 -144
- data/try/features/encryption_fields/aad_protection_try.rb +108 -23
- data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
- data/try/features/encryption_fields/context_isolation_try.rb +29 -8
- data/try/features/encryption_fields/error_conditions_try.rb +6 -6
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
- data/try/features/encryption_fields/fresh_key_try.rb +27 -22
- data/try/features/encryption_fields/key_rotation_try.rb +16 -10
- data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
- data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
- data/try/features/encryption_fields/thread_safety_try.rb +6 -6
- data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
- data/try/features/feature_dependencies_try.rb +3 -3
- data/try/features/transient_fields_core_try.rb +1 -1
- data/try/features/transient_fields_integration_try.rb +1 -1
- data/try/helpers/test_helpers.rb +25 -0
- data/try/horreum/enhanced_conflict_handling_try.rb +1 -1
- data/try/horreum/initialization_try.rb +1 -1
- data/try/horreum/relations_try.rb +1 -1
- data/try/horreum/serialization_persistent_fields_try.rb +8 -8
- data/try/horreum/serialization_try.rb +39 -4
- data/try/models/customer_safe_dump_try.rb +1 -1
- data/try/models/customer_try.rb +1 -1
- metadata +51 -10
- data/TEST_COVERAGE.md +0 -40
- data/lib/familia/horreum/serialization.rb +0 -473
@@ -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.
|