Class: Familia::DataType Abstract
- Inherits:
-
Object
- Object
- Familia::DataType
- 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
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.
Direct Known Subclasses
HashKey, JsonStringKey, ListKey, SortedSet, StringKey, UnsortedSet
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
-
.has_related_fields ⇒ Object
readonly
Returns the value of attribute has_related_fields.
-
.registered_types ⇒ Object
readonly
Returns the value of attribute registered_types.
-
.valid_options ⇒ Object
readonly
Returns the value of attribute valid_options.
Instance Attribute Summary collapse
-
#features_enabled ⇒ Object
included
from Features
readonly
Returns the value of attribute features_enabled.
- #logical_database(val = nil) ⇒ Object included from ClassMethods
-
#parent ⇒ Object
included
from ClassMethods
Returns the value of attribute parent.
-
#prefix ⇒ Object
included
from ClassMethods
Returns the value of attribute prefix.
-
#suffix ⇒ Object
included
from ClassMethods
Returns the value of attribute suffix.
-
#uri(val = nil) ⇒ Object
included
from ClassMethods
Returns the value of attribute uri.
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
-
.feature(feature_name = nil, **options) ⇒ Array?
extended
from Features
Enables a feature for the current class with optional configuration.
- .inherited(obj) ⇒ Object extended from ClassMethods
-
.register(klass, methname) ⇒ Object
extended
from 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
extended
from 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 extended from ClassMethods
- .valid_keys_only(opts) ⇒ Object extended from ClassMethods
Instance Method Summary collapse
-
#default_expiration ⇒ Numeric
Override the default_expiration instance method to inherit from the parent Horreum when this DataType doesn't have its own explicit default_expiration option.
-
#initialize(keystring, opts = {}) ⇒ DataType
constructor
+keystring+: If parent is set, this will be used as the suffix for dbkey.
-
#warn_if_dirty! ⇒ void
Checks if the parent Horreum object has unsaved scalar field changes and emits a warning (or raises) before a collection write.
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
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
.has_related_fields ⇒ Object (readonly)
Returns the value of attribute has_related_fields.
113 114 115 |
# File 'lib/familia/data_type.rb', line 113 def @has_related_fields end |
.registered_types ⇒ Object (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_options ⇒ Object (readonly)
Returns the value of attribute valid_options.
113 114 115 |
# File 'lib/familia/data_type.rb', line 113 def @valid_options end |
Instance Attribute Details
#features_enabled ⇒ Object (readonly) Originally defined in module Features
Returns the value of attribute features_enabled.
#logical_database(val = nil) ⇒ Object Originally defined in module ClassMethods
#parent ⇒ Object Originally defined in module ClassMethods
Returns the value of attribute parent.
#prefix ⇒ Object Originally defined in module ClassMethods
Returns the value of attribute prefix.
#suffix ⇒ Object 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.
.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
.valid_keys_only(opts) ⇒ Object Originally defined in module ClassMethods
Instance Method Details
#default_expiration ⇒ Numeric
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:
- Instance-level @default_expiration (set directly)
- Explicit opts:default_expiration
- Parent Horreum's default_expiration (cascade)
- Class-level default (Familia.default_expiration, typically 0)
Relations with no_expiration: true are excluded from cascade and
always return 0 (no TTL).
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:
- 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.
- 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.
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 = (new_record, dirty) raise Familia::Problem, if raise_on_dirty_write?(mode, new_record) emit_dirty_warning(mode, , dirty) end |