Module: Familia::Features::Expiration
- Defined in:
- lib/familia/features/expiration.rb
Overview
Expiration is a feature that provides Time To Live (TTL) management for Familia objects and their associated Valkey/Redis data structures. It enables automatic data cleanup and supports cascading expiration across related objects.
This feature allows you to:
- UnsortedSet default expiration times at the class level
- Update expiration times for individual objects
- Cascade expiration settings to related data structures
- Query remaining TTL for objects
- Handle expiration inheritance in class hierarchies
Example:
class Session < Familia::Horreum feature :expiration default_expiration 1.hour
field :user_id, :data, :created_at
list :activity_log
end
session = Session.new(user_id: 123, data: { role: 'admin' }) session.save
# Automatically expires in 1 hour (default_expiration) session.ttl # => 3599 (seconds remaining)
# Update expiration to 30 minutes session.update_expiration(30.minutes) session.ttl # => 1799
# UnsortedSet custom expiration for new objects session.update_expiration(expiration: 2.hours)
Class-Level Configuration:
Default expiration can be set at the class level and will be inherited by subclasses unless overridden:
class BaseModel < Familia::Horreum feature :expiration default_expiration 1.day end
class ShortLivedModel < BaseModel default_expiration 5.minutes # Overrides parent end
class InheritedModel < BaseModel # Inherits 1.day from BaseModel end
Cascading Expiration:
When an object has related data structures (lists, sets, etc.), the expiration feature cascades TTL to all related structures during +update_expiration+ (which is called automatically by +save+). Each relation receives either its own +default_expiration+ or the parent value as a fallback. Relations with +no_expiration: true+ are excluded from cascade entirely.
class User < Familia::Horreum feature :expiration default_expiration 30.days
field :email, :name
list :sessions # Inherits parent TTL (30 days) via cascade
set :permissions # Inherits parent TTL (30 days) via cascade
hashkey :preferences # Inherits parent TTL (30 days) via cascade
end
Note: cascade applies EXPIRE to the relation's key, so the key must already exist in the database. Relations populated after +save+ will receive TTL from their own write methods (if they have +default_expiration+) or from a subsequent +update_expiration+ call.
Fine-Grained Control:
Related structures can have their own expiration settings, or opt out of expiration cascade entirely:
class Analytics < Familia::Horreum feature :expiration default_expiration 1.year
field :metric_name
list :hourly_data, default_expiration: 1.week # Own TTL (1 week)
list :daily_data, default_expiration: 1.month # Own TTL (1 month)
list :monthly_data # Inherits class TTL (1 year) via cascade
hashkey :permanent_config, no_expiration: true # Excluded from cascade
end
Zero Expiration:
Setting expiration to 0 (zero) disables TTL, making data persist indefinitely:
session.update_expiration(expiration: 0) # No expiration
TTL Querying:
Check remaining time before expiration:
session.ttl # => 3599 (seconds remaining) session.ttl.zero? # => false (still has time) expired_session.ttl # => -1 (already expired or no TTL set)
Integration Patterns:
# Conditional expiration based on user type class UserSession < Familia::Horreum feature :expiration
field :user_id, :user_type
def save
super
case user_type
when 'premium'
update_expiration(7.days)
when 'free'
update_expiration(1.hour)
else
update_expiration(expiration)
end
end
end
# Background job cleanup class DataCleanupJob def perform # Extend expiration for active users active_sessions = Session.where(active: true) active_sessions.each do |session| session.update_expiration(session.default_expiration) end end end
Error Handling:
The feature validates expiration values and raises descriptive errors:
session.update_expiration(expiration: "invalid") # => Familia::Problem: Default expiration must be a number
session.update_expiration(expiration: -1) # => Familia::Problem: Default expiration must be non-negative
Performance Considerations:
- TTL operations are performed on Valkey/Redis side with minimal overhead
- Cascading expiration uses pipelining for efficiency when possible
- Zero expiration values skip Valkey/Redis EXPIRE calls entirely
- TTL queries are direct db operations (very fast)
Defined Under Namespace
Modules: ModelClassMethods
Instance Method Summary collapse
-
#default_expiration ⇒ Float
Get the default expiration time for this instance.
-
#default_expiration=(num) ⇒ Object
UnsortedSet the default expiration time for this instance.
-
#expired?(threshold = 0) ⇒ Boolean
Check if this object's data has expired or will expire soon.
-
#expires? ⇒ Boolean
Check if this object's data will expire.
-
#extend_expiration(duration) ⇒ Boolean
Extend the expiration time by the specified duration.
-
#persist! ⇒ Boolean
Remove expiration, making the object persist indefinitely.
-
#ttl ⇒ Integer
Get the remaining time to live for this object's data.
-
#ttl_report ⇒ Hash
Returns a report of TTL values for the main key and all relation keys.
-
#update_expiration(expiration: nil) ⇒ Boolean
Sets an expiration time for the Valkey/Redis data associated with this object.
Instance Method Details
#default_expiration ⇒ Float
Get the default expiration time for this instance
Returns the instance-specific default expiration, falling back to class default expiration if not set.
232 233 234 |
# File 'lib/familia/features/expiration.rb', line 232 def default_expiration @default_expiration || self.class.default_expiration end |
#default_expiration=(num) ⇒ Object
UnsortedSet the default expiration time for this instance
221 222 223 |
# File 'lib/familia/features/expiration.rb', line 221 def default_expiration=(num) @default_expiration = num.to_f end |
#expired?(threshold = 0) ⇒ Boolean
Check if this object's data has expired or will expire soon
359 360 361 362 363 364 365 |
# File 'lib/familia/features/expiration.rb', line 359 def expired?(threshold = 0) current_ttl = ttl return false if current_ttl == -1 # no expiration set return true if current_ttl == -2 # key does not exist current_ttl <= threshold end |
#expires? ⇒ Boolean
Check if this object's data will expire
344 345 346 |
# File 'lib/familia/features/expiration.rb', line 344 def expires? ttl.positive? end |
#extend_expiration(duration) ⇒ Boolean
Extend the expiration time by the specified duration
This adds the given duration to the current TTL, effectively extending the object's lifetime without changing the default expiration setting.
378 379 380 381 382 383 384 |
# File 'lib/familia/features/expiration.rb', line 378 def extend_expiration(duration) current_ttl = ttl return false unless current_ttl.positive? # no current expiration set new_ttl = current_ttl + duration.to_f expire(new_ttl) end |
#persist! ⇒ Boolean
Remove expiration, making the object persist indefinitely
393 394 395 |
# File 'lib/familia/features/expiration.rb', line 393 def persist! dbclient.persist(dbkey) end |
#ttl ⇒ Integer
Get the remaining time to live for this object's data
336 337 338 |
# File 'lib/familia/features/expiration.rb', line 336 def ttl dbclient.ttl(dbkey) end |
#ttl_report ⇒ Hash
Returns a report of TTL values for the main key and all relation keys.
This is useful for detecting TTL drift where the main hash has a TTL but one or more relation keys do not (or vice versa). Queries all keys using pipelined TTL calls for efficiency.
TTL values follow Redis conventions:
- Positive integer: seconds remaining
- -1: key exists but has no expiration
- -2: key does not exist
428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 |
# File 'lib/familia/features/expiration.rb', line 428 def ttl_report # Collect all keys upfront: main key first, then relation keys main_key = dbkey relation_names = [] relation_keys = [] if self.class.relations? self.class..each_key do |name| relation_names << name relation_keys << send(name).dbkey end end all_keys = [main_key, *relation_keys] # Batch all TTL queries into a single round-trip multi_result = pipelined do |pipe| all_keys.each { |key| pipe.ttl(key) } end ttl_values = multi_result.results # Build report from pipelined results report = { main: { key: main_key, ttl: ttl_values[0] }, relations: {}, } relation_names.each_with_index do |name, idx| report[:relations][name] = { key: relation_keys[idx], ttl: ttl_values[idx + 1], } end report end |
#update_expiration(expiration: nil) ⇒ Boolean
If default expiration is set to zero, the expiration will be removed, making the data persist indefinitely.
Sets an expiration time for the Valkey/Redis data associated with this object
This method allows setting a Time To Live (TTL) for the data in Valkey/Redis, after which it will be automatically removed. The method also handles cascading expiration to related data structures when applicable.
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 |
# File 'lib/familia/features/expiration.rb', line 263 def update_expiration(expiration: nil) expiration ||= default_expiration # Handle cascading expiration to related data structures. # # By default, all relations inherit the parent object's expiration. # Relations with an explicit `default_expiration:` option use that # value instead. Relations with `no_expiration: true` are excluded # from cascade entirely and persist independently. if self.class.relations? Familia.debug "[update_expiration] #{self.class} has relations: #{self.class..keys}" self.class..each do |name, definition| # Skip relations explicitly excluded from expiration cascade next if definition.opts[:no_expiration] # Use the relation's own default_expiration when defined, # falling back to the parent expiration value. This allows # per-relation TTL (e.g. list :hourly_data, default_expiration: 1.week) # to take precedence over the class-level default_expiration. rel_expiration = definition.opts[:default_expiration] || expiration obj = send(name) Familia.debug "[update_expiration] Updating expiration for #{name} (#{obj.dbkey}) to #{rel_expiration}" obj.update_expiration(expiration: rel_expiration) end end # Validate expiration value # It's important to raise exceptions here and not just log warnings. We # don't want to silently fail at setting expirations and cause data # retention issues (e.g. not removed in a timely fashion). unless expiration.is_a?(Numeric) raise Familia::Problem, "Default expiration must be a number (#{expiration.class} given for #{self.class})" end unless expiration >= 0 raise Familia::Problem, "Default expiration must be non-negative (#{expiration} given for #{self.class})" end # If zero, simply skip setting an expiry for this key. If we were to set # 0, Valkey/Redis would drop the key immediately. if expiration.zero? Familia.debug "[update_expiration] No expiration for #{self.class} (#{dbkey})" return true end # Structured TTL operation logging Familia.debug "TTL updated", operation: :expire, key: dbkey, ttl_seconds: expiration, class: self.class.name, identifier: (identifier rescue nil) # The Valkey/Redis' EXPIRE command returns 1 if the timeout was set, 0 # if key does not exist or the timeout could not be set. Via redis-rb, # it's a boolean. expire(expiration) end |