Class: Familia::DataType Abstract

Inherits:
Object
  • Object
show all
Extended by:
ClassMethods, Features
Includes:
Base, Connection, DatabaseCommands, Serialization, Settings
Defined in:
lib/familia/data_type.rb,
lib/familia/data_type/settings.rb,
lib/familia/data_type/connection.rb,
lib/familia/data_type/scalar_base.rb,
lib/familia/data_type/class_methods.rb,
lib/familia/data_type/serialization.rb,
lib/familia/data_type/collection_base.rb,
lib/familia/data_type/database_commands.rb

Overview

This class is abstract.

Subclass and implement Database data type specific methods

DataType - Base class for Database data type wrappers

This class provides common functionality for various Database data types such as String, JsonStringKey, List, UnsortedSet, SortedSet, and HashKey.

== Mental Model: Live Proxies, Not Cached Relations

Unlike ActiveRecord relations which return new objects that can cache loaded records, DataType instances are:

  • Memoized: Same object on every access (stable object_id)
  • Uncached: Every read method hits Redis — no local data cache
  • Frozen: Class-level DataTypes are frozen for thread safety

This means define_singleton_method raises FrozenError on class-level DataTypes. To stub in tests, stub the class method returning the DataType.

== Write Method Transaction Safety Audit (2026-02-25)

All write methods use dbclient which is transaction-aware: inside a Horreum#transaction block, Fiber[:familia_transaction] routes commands through the transaction connection. Outside a transaction, each write is a standalone command followed by a separate EXPIRE (2 round trips, no atomicity guarantee between them).

Methods marked read-then-write are NOT atomic outside of transactions.

Type Method Redis Cmd update_exp Read-then-write
UnsortedSet add SADD yes no
UnsortedSet remove_element SREM yes no
UnsortedSet pop SPOP yes no
UnsortedSet move SMOVE yes no
SortedSet add ZADD yes no
SortedSet remove_element ZREM yes no
SortedSet increment ZINCRBY yes no
SortedSet decrement ZINCRBY (neg) yes (via increment) no
SortedSet remrangebyrank ZREMRANGEBYRANK yes no
SortedSet remrangebyscore ZREMRANGEBYSCORE yes no
HashKey []= HSET yes no
HashKey hsetnx HSETNX conditional no
HashKey remove_field HDEL yes no
HashKey increment HINCRBY yes no
HashKey decrement HINCRBY (neg) yes (via increment) no
HashKey update HMSET yes no
ListKey push RPUSH(+LTRIM) yes no
ListKey unshift LPUSH(+LTRIM) yes no
ListKey pop RPOP yes no
ListKey shift LPOP yes no
ListKey remove_element LREM yes no
StringKey value= SET yes no
StringKey setnx SETNX yes no
StringKey increment INCR yes no
StringKey incrementby INCRBY yes no
StringKey decrement DECR yes no
StringKey decrementby DECRBY yes no
StringKey append APPEND yes no
StringKey setbit SETBIT yes no
StringKey setrange SETRANGE yes no
StringKey getset GETSET yes no
StringKey del DEL no no
Counter reset SET (via set) yes (via value=) no
Counter incr_if_lt EVAL (Lua) yes no (atomic Lua)
Lock acquire SETNX(+EXPIRE) yes (via setnx) no
Lock release EVAL (Lua) no (deletes key) no (atomic Lua)
Lock force_unlock! DEL no (deletes key) no

Notes:

  • Counter#increment_if_less_than uses a Lua script (EVAL) for atomic threshold check + increment. Previously used GET then conditional INCRBY which was not atomic outside of a transaction.
  • Lock#release uses a Lua script (EVAL) which IS atomic on the server.
  • StringKey#del and Lock methods that delete the key do not call update_expiration because the key no longer exists.
  • HashKey#hsetnx only calls update_expiration when the field was actually set (ret == 1), which is correct conditional behavior.

Defined Under Namespace

Modules: ClassMethods, CollectionBase, Connection, DatabaseCommands, ScalarBase, Serialization, Settings

Constant Summary collapse

DIRTY_WRITE_HINT =

Remediation hint appended to every dirty-write warning/raise message so the fix is self-evident without a round trip back to the docs.

'(call #save first or wrap in atomic_write)'

Class Attribute Summary collapse

Instance Attribute Summary collapse

Attributes included from Settings

#current_key_version, #delim, #encryption_keys, #encryption_personalization, #logical_database, #prefix, #raise_on_unsaved_parent_write, #schema_path, #schema_validator, #schemas, #strict_write_order, #suffix, #transaction_mode

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Features::Autoloader

autoload_files, included, normalize_to_config_name

Methods included from Serialization

#deserialize_value, #deserialize_values, #deserialize_values_with_nil, #serialize_value, #strip_legacy_json_encoding

Methods included from DatabaseCommands

#current_expiration, #delete!, #echo, #exists?, #expire, #expireat, #move, #persist, #rename, #renamenx, #type

Methods included from Connection

#dbclient, #dbkey, #uri

Methods included from Connection::Behavior

#connect, #create_dbclient, #multi, #normalize_uri, #pipeline, #pipelined, #transaction, #uri=, #url, #url=

Methods included from Settings

#configure, #default_suffix, #dirty_write_warnings, #dirty_write_warnings=, #pipelined_mode, #pipelined_mode=

Methods included from Base

add_feature, #as_json, #expired?, #expires?, find_feature, #generate_id, #to_json, #to_s, #ttl, #update_expiration, #uuid

Constructor Details

#initialize(keystring, opts = {}) ⇒ DataType

+keystring+: If parent is set, this will be used as the suffix for dbkey. Otherwise this becomes the value of the key. If this is an Array, the elements will be joined.

Options:

:class => A class that responds to from_json. This will be used when loading data from the database to unmarshal the class. JSON serialization is used for all data storage.

:parent => The Familia object that this datatype object belongs to. This can be a class that includes Familia or an instance.

:default_expiration => the time to live in seconds. When not nil, this will set the default expiration for this dbkey whenever #save is called. You can also call it explicitly via #update_expiration.

:default => the default value (String-only)

:dbkey => a hardcoded key to use instead of the deriving the from the name and parent (e.g. a derived key: customer:custid:secret_counter).

:suffix => the suffix to use for the key (e.g. 'scores' in customer:custid:scores). :prefix => the prefix to use for the key (e.g. 'customer' in customer:custid:scores).

Connection precendence: uses the database connection of the parent or the value of opts[:dbclient] or Familia.dbclient (in that order).



143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/familia/data_type.rb', line 143

def initialize(keystring, opts = {})
  @keystring = keystring
  @keystring = @keystring.join(Familia.delim) if @keystring.is_a?(Array)

  # Remove all keys from the opts that are not in the allowed list
  @opts = DataType.valid_keys_only(opts || {})

  # Apply the options to instance method setters of the same name
  @opts.each do |k, v|
    send(:"#{k}=", v) if respond_to? :"#{k}="
  end

  init if respond_to? :init
end

Class Attribute Details

Returns the value of attribute has_related_fields.



113
114
115
# File 'lib/familia/data_type.rb', line 113

def has_related_fields
  @has_related_fields
end

.registered_typesObject (readonly)

Returns the value of attribute registered_types.



113
114
115
# File 'lib/familia/data_type.rb', line 113

def registered_types
  @registered_types
end

.valid_optionsObject (readonly)

Returns the value of attribute valid_options.



113
114
115
# File 'lib/familia/data_type.rb', line 113

def valid_options
  @valid_options
end

Instance Attribute Details

#features_enabledObject (readonly) Originally defined in module Features

Returns the value of attribute features_enabled.

#logical_database(val = nil) ⇒ Object Originally defined in module ClassMethods

#parentObject Originally defined in module ClassMethods

Returns the value of attribute parent.

#prefixObject Originally defined in module ClassMethods

Returns the value of attribute prefix.

#suffixObject Originally defined in module ClassMethods

Returns the value of attribute suffix.

#uri(val = nil) ⇒ Object Originally defined in module ClassMethods

Returns the value of attribute uri.

Class Method Details

.feature(feature_name = nil, **options) ⇒ Array? Originally defined in module Features

Enables a feature for the current class with optional configuration.

Features are modular capabilities that can be mixed into Familia::Horreum classes. Each feature can be configured with options that are stored per-class, ensuring complete isolation between different models.

Examples:

Enable feature without options

class User < Familia::Horreum
  feature :expiration
end

Enable feature with options (per-class storage)

class User < Familia::Horreum
  feature :object_identifier, generator: :uuid_v4
end

class Session < Familia::Horreum
  feature :object_identifier, generator: :hex  # Different options
end

# Each class maintains separate options:
User.feature_options(:object_identifier)    #=> {generator: :uuid_v4}
Session.feature_options(:object_identifier) #=> {generator: :hex}

Parameters:

  • feature_name (Symbol, String, nil) (defaults to: nil)

    the name of the feature to enable. If nil, returns the list of currently enabled features.

  • options (Hash)

    configuration options for the feature. These are stored per-class and do not interfere with other models' configurations.

Returns:

  • (Array, nil)

    the list of enabled features if feature_name is nil, otherwise nil

Raises:

.inherited(obj) ⇒ Object Originally defined in module ClassMethods

.register(klass, methname) ⇒ Object Originally defined in module ClassMethods

To be called inside every class that inherits DataType +methname+ is the term used for the class and instance methods that are created for the given +klass+ (e.g. set, list, etc)

.registered_type(methname) ⇒ Object Originally defined in module ClassMethods

Get the registered type class from a given method name +methname+ is the method name used to register the class (e.g. :set, :list, etc) Returns the registered class or nil if not found

.relations?Boolean Originally defined in module ClassMethods

Returns:

  • (Boolean)

.valid_keys_only(opts) ⇒ Object Originally defined in module ClassMethods

Instance Method Details

#default_expirationNumeric

Override the default_expiration instance method to inherit from the parent Horreum when this DataType doesn't have its own explicit default_expiration option. This enables TTL cascade: when a Horreum class has default_expiration 1.hour and a relation like set :tags doesn't specify its own, the tags set will use the parent's TTL.

Precedence:

  1. Instance-level @default_expiration (set directly)
  2. Explicit opts:default_expiration
  3. Parent Horreum's default_expiration (cascade)
  4. Class-level default (Familia.default_expiration, typically 0)

Relations with no_expiration: true are excluded from cascade and always return 0 (no TTL).

Returns:

  • (Numeric)

    The expiration in seconds



345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/familia/data_type.rb', line 345

def default_expiration
  return 0 if @opts && @opts[:no_expiration]

  # Check instance-level override first
  return @default_expiration if @default_expiration

  # Check explicit opts from relation declaration
  return @opts[:default_expiration] if @opts && @opts[:default_expiration]

  # Inherit from parent Horreum if available
  if @parent_ref.respond_to?(:default_expiration)
    parent_exp = @parent_ref.default_expiration
    return parent_exp if parent_exp && parent_exp > 0
  end

  # Fall back to class-level default
  self.class.default_expiration
end

#warn_if_dirty!void

This method returns an undefined value.

Checks if the parent Horreum object has unsaved scalar field changes and emits a warning (or raises) before a collection write.

This guards against a subtle issue where collection operations (SADD, RPUSH, ZADD, HSET) write to Redis immediately while scalar field changes remain only in memory. If the process crashes before the scalar fields are saved, the collection data is persisted but the scalar data is lost, creating an inconsistent state.

Two flavours of this hazard are distinguished:

  1. New, unsaved parent — the parent has never been persisted, so no hash key exists in Redis yet. This is the worst case: the collection write creates a key while none of the scalar data exists, leaving an orphaned collection with no parent hash if the process never saves.
  2. Dirty after save — the parent was persisted before and merely has uncommitted scalar changes. The parent hash already exists, so the inconsistency is a partial update rather than a fully orphaned record.

The behavior splits into a raise path and a warning path:

  • Raise (exempt from dedup): when Familia.strict_write_order is true, when the resolved class mode is :strict, or when the parent is new & unsaved and Familia.raise_on_unsaved_parent_write is true (the default). The new-object case gets a distinct, stronger message because orphaning a record is rarely intended.
  • Warn: otherwise the resolved dirty_write_warnings mode governs emission (see #resolve_dirty_warning_mode) -- :once (default) warns once per distinct dirty-field signature within a dirty window (deduped via the parent's #record_dirty_warning!), :warn warns on every collection write, and :off suppresses entirely.

An active +atomic_write+ block suppresses everything, taking priority over all of the above. Every message ends with the DIRTY_WRITE_HINT remediation.

Raises:

  • (Familia::Problem)

    when Familia.strict_write_order is true, when the resolved mode is :strict, or when the parent is a new, unsaved object and Familia.raise_on_unsaved_parent_write is true (the default)



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/familia/data_type.rb', line 198

def warn_if_dirty!
  # Suppress warnings while parent is inside atomic_write — scalar setters in the block
  # make the object dirty by design, so firing warnings for each collection call is noise.
  return if @parent_ref.respond_to?(:atomic_write_mode?) && @parent_ref.atomic_write_mode?

  return unless @parent_ref.respond_to?(:dirty?) && @parent_ref.dirty?

  mode = resolve_dirty_warning_mode
  # "Off means off": an explicit :off opts the class out of dirty-write
  # diagnostics entirely -- no warning AND no raise. The class-level mode is
  # the most specific signal, so it overrides both global raise switches
  # (strict_write_order and raise_on_unsaved_parent_write), mirroring how a
  # local "ignore" beats a global warnings-as-errors escalation elsewhere.
  return if mode == :off

  new_record = parent_new_record?
  dirty      = @parent_ref.dirty_fields
  message    = dirty_write_message(new_record, dirty)

  raise Familia::Problem, message if raise_on_dirty_write?(mode, new_record)

  emit_dirty_warning(mode, message, dirty)
end