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

Instance Method Details

#default_expirationFloat

Get the default expiration time for this instance

Returns the instance-specific default expiration, falling back to class default expiration if not set.

Returns:

  • (Float)

    The default expiration in seconds



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

Parameters:

  • num (Numeric)

    Expiration time in seconds



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

Examples:

Check if expired

session.expired?  # => true if TTL <= 0

Check if expiring within 5 minutes

session.expired?(5.minutes)  # => true if TTL <= 300

Parameters:

  • threshold (Numeric) (defaults to: 0)

    Consider expired if TTL is below this threshold (default: 0)

Returns:

  • (Boolean)

    true if expired or expiring 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

Returns:

  • (Boolean)

    true if TTL is set, false if data persists indefinitely



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.

Examples:

Extend session by 1 hour

session.extend_expiration(1.hour)

Parameters:

  • duration (Numeric)

    Additional time in seconds

Returns:

  • (Boolean)

    Success of the operation



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

Examples:

Make session persistent

session.persist!

Returns:

  • (Boolean)

    Success of the operation



393
394
395
# File 'lib/familia/features/expiration.rb', line 393

def persist!
  dbclient.persist(dbkey)
end

#ttlInteger

Get the remaining time to live for this object's data

Examples:

Check remaining TTL

session.ttl  # => 3599 (expires in ~1 hour)
session.ttl.zero?  # => false

Check if expired or no TTL

expired_session.ttl  # => -1

Returns:

  • (Integer)

    Seconds remaining before expiration, or -1 if no TTL is set



336
337
338
# File 'lib/familia/features/expiration.rb', line 336

def ttl
  dbclient.ttl(dbkey)
end

#ttl_reportHash

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

Examples:

Inspect TTL across all keys

session.ttl_report
# => {
#      main: { key: "session:abc123:object", ttl: 3599 },
#      relations: {
#        sessions: { key: "session:abc123:sessions", ttl: -1 },
#        tags:     { key: "session:abc123:tags", ttl: 3598 }
#      }
#    }

Detect TTL drift

report = user.ttl_report
drifted = report[:relations].select { |_, v| v[:ttl] == -1 }
warn "TTL drift detected: #{drifted.keys}" if drifted.any?

Returns:

  • (Hash)

    A hash with :main and :relations keys

    • :main [Hash] { key: String, ttl: Integer }
    • :relations [Hash=> Hash] Each relation name maps to { key: String, ttl: Integer }


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.related_fields.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

Note:

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.

Examples:

Setting an expiration of one day

object.update_expiration(expiration: 86400)

Using default expiration

object.update_expiration  # Uses class default_expiration

Removing expiration (persist indefinitely)

object.update_expiration(expiration: 0)

Parameters:

  • expiration (Numeric, nil) (defaults to: nil)

    The Time To Live in seconds. If nil, the default TTL will be used.

Returns:

  • (Boolean)

    Returns true if the expiration was set successfully, false otherwise.

Raises:

  • (Familia::Problem)

    Raises an error if the default expiration is not a non-negative number.



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.related_fields.keys}"
    self.class.related_fields.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