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 automatically applies TTL to all related structures:

class User < Familia::Horreum feature :expiration default_expiration 30.days

field :email, :name
list :sessions        # Will also expire in 30 days
set :permissions      # Will also expire in 30 days
hashkey :preferences  # Will also expire in 30 days

end

Fine-Grained Control:

Related structures can have their own expiration settings:

class Analytics < Familia::Horreum feature :expiration default_expiration 1.year

field :metric_name
list :hourly_data, default_expiration: 1.week    # Shorter TTL
list :daily_data, default_expiration: 1.month    # Medium TTL
list :monthly_data  # Uses class default (1.year)

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



221
222
223
# File 'lib/familia/features/expiration.rb', line 221

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



210
211
212
# File 'lib/familia/features/expiration.rb', line 210

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



337
338
339
340
341
342
343
# File 'lib/familia/features/expiration.rb', line 337

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



322
323
324
# File 'lib/familia/features/expiration.rb', line 322

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



356
357
358
359
360
361
362
# File 'lib/familia/features/expiration.rb', line 356

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



371
372
373
# File 'lib/familia/features/expiration.rb', line 371

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



314
315
316
# File 'lib/familia/features/expiration.rb', line 314

def ttl
  dbclient.ttl(dbkey)
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.



252
253
254
255
256
257
258
259
260
261
262
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
# File 'lib/familia/features/expiration.rb', line 252

def update_expiration(expiration: nil)
  expiration ||= default_expiration

  # Handle cascading expiration to related data structures
  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 that don't have their own expiration settings
      next if definition.opts[:default_expiration].nil?

      obj = send(name)
      Familia.debug "[update_expiration] Updating expiration for #{name} (#{obj.dbkey}) to #{expiration}"
      obj.update_expiration(expiration: 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