familia 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +95 -0
  3. data/CLAUDE.md +84 -0
  4. data/Gemfile.lock +1 -1
  5. data/README.md +1 -1
  6. data/docs/guides/feature-encrypted-fields.md +4 -4
  7. data/docs/guides/feature-relationships-indexing.md +1 -1
  8. data/docs/guides/feature-relationships-participation.md +1 -1
  9. data/docs/guides/feature-relationships.md +1 -1
  10. data/docs/guides/feature-system.md +1 -1
  11. data/docs/guides/field-system.md +3 -3
  12. data/docs/guides/optimized-loading.md +2 -2
  13. data/examples/datatype_standalone.rb +1 -1
  14. data/examples/encrypted_fields.rb +4 -4
  15. data/examples/json_usage_patterns.rb +2 -2
  16. data/lib/familia/connection/behavior.rb +1 -1
  17. data/lib/familia/connection/transaction_core.rb +1 -1
  18. data/lib/familia/data_type/types/counter.rb +14 -5
  19. data/lib/familia/data_type/types/hashkey.rb +11 -2
  20. data/lib/familia/data_type/types/json_stringkey.rb +1 -1
  21. data/lib/familia/data_type/types/listkey.rb +17 -3
  22. data/lib/familia/data_type/types/sorted_set.rb +21 -8
  23. data/lib/familia/data_type/types/stringkey.rb +4 -0
  24. data/lib/familia/data_type/types/unsorted_set.rb +13 -3
  25. data/lib/familia/data_type.rb +123 -1
  26. data/lib/familia/features/expiration.rb +104 -13
  27. data/lib/familia/features/relationships/participation.rb +2 -2
  28. data/lib/familia/field_type.rb +14 -1
  29. data/lib/familia/horreum/connection.rb +3 -3
  30. data/lib/familia/horreum/definition.rb +7 -0
  31. data/lib/familia/horreum/dirty_tracking.rb +109 -0
  32. data/lib/familia/horreum/management/audit.rb +395 -0
  33. data/lib/familia/horreum/management/audit_report.rb +104 -0
  34. data/lib/familia/horreum/management/repair.rb +376 -0
  35. data/lib/familia/horreum/management.rb +147 -22
  36. data/lib/familia/horreum/persistence.rb +219 -15
  37. data/lib/familia/horreum/serialization.rb +34 -0
  38. data/lib/familia/horreum.rb +6 -0
  39. data/lib/familia/logging.rb +1 -1
  40. data/lib/familia/settings.rb +21 -1
  41. data/lib/familia/thread_safety/monitor.rb +4 -4
  42. data/lib/familia/version.rb +1 -1
  43. data/lib/middleware/database_logger.rb +4 -4
  44. data/try/audit/audit_instances_try.rb +119 -0
  45. data/try/audit/audit_report_try.rb +182 -0
  46. data/try/audit/audit_unique_indexes_try.rb +92 -0
  47. data/try/audit/compound_identifier_try.rb +119 -0
  48. data/try/audit/health_check_try.rb +101 -0
  49. data/try/audit/m2_rebuild_batch_try.rb +110 -0
  50. data/try/audit/m3_multi_index_stub_try.rb +175 -0
  51. data/try/audit/m5_repair_batched_try.rb +144 -0
  52. data/try/audit/participation_audit_try.rb +121 -0
  53. data/try/audit/participation_instance_audit_try.rb +206 -0
  54. data/try/audit/participation_list_audit_try.rb +187 -0
  55. data/try/audit/participation_set_audit_try.rb +101 -0
  56. data/try/audit/rebuild_instances_try.rb +117 -0
  57. data/try/audit/repair_all_integration_try.rb +150 -0
  58. data/try/audit/repair_all_try.rb +84 -0
  59. data/try/audit/repair_indexes_try.rb +77 -0
  60. data/try/audit/repair_instances_try.rb +94 -0
  61. data/try/audit/repair_participations_try.rb +182 -0
  62. data/try/audit/repair_score_correctness_try.rb +139 -0
  63. data/try/audit/scan_keys_try.rb +66 -0
  64. data/try/edge_cases/find_by_dbkey_race_condition_try.rb +66 -76
  65. data/try/features/atomic_counter_try.rb +134 -0
  66. data/try/features/atomicity_try.rb +138 -0
  67. data/try/features/count_any_edge_cases_try.rb +4 -4
  68. data/try/features/dirty_tracking_try.rb +504 -0
  69. data/try/features/expiration_cascade_try.rb +136 -0
  70. data/try/features/field_groups_try.rb +2 -2
  71. data/try/features/ghost_objects_try.rb +124 -0
  72. data/try/features/instance_registry_try.rb +112 -0
  73. data/try/features/load_missing_keys_try.rb +112 -0
  74. data/try/features/registry_methods_try.rb +125 -0
  75. data/try/features/relationships/participation_commands_verification_spec.rb +6 -7
  76. data/try/features/relationships/participation_commands_verification_try.rb +3 -3
  77. data/try/features/relationships/participation_performance_improvements_try.rb +1 -1
  78. data/try/features/relationships/participation_reverse_index_try.rb +2 -2
  79. data/try/features/relationships/participation_target_class_resolution_try.rb +2 -2
  80. data/try/features/safe_dump/safe_dump_advanced_try.rb +1 -1
  81. data/try/features/save_with_collections_try.rb +75 -0
  82. data/try/features/serialization_debug_try.rb +159 -0
  83. data/try/features/ttl_report_try.rb +183 -0
  84. data/try/integration/connection/transaction_fallback_integration_try.rb +1 -1
  85. data/try/integration/connection/transaction_mode_permissive_try.rb +1 -1
  86. data/try/integration/models/customer_try.rb +4 -4
  87. data/try/integration/relationships_persistence_round_trip_try.rb +1 -1
  88. data/try/integration/transaction_safety_core_try.rb +1 -1
  89. data/try/integration/transaction_safety_workflow_try.rb +11 -11
  90. data/try/migration/integration_try.rb +1 -1
  91. data/try/migration/model_try.rb +1 -1
  92. data/try/migration/pipeline_try.rb +2 -2
  93. data/try/migration/registry_try.rb +1 -1
  94. data/try/migration/runner_try.rb +1 -1
  95. data/try/migration/v1_to_v2_serialization_try.rb +2 -2
  96. data/try/performance/transaction_safety_benchmark_try.rb +20 -20
  97. data/try/support/benchmarks/deserialization_benchmark.rb +1 -1
  98. data/try/support/benchmarks/deserialization_correctness_test.rb +1 -1
  99. data/try/unit/data_types/counter_try.rb +2 -2
  100. data/try/unit/horreum/serialization_try.rb +4 -4
  101. data/try/unit/horreum/unique_index_guard_validation_try.rb +1 -1
  102. metadata +36 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5608f472e264e8cbf6228e398d4a25517173e55ad0d7d10a2b74de31121b18f
4
- data.tar.gz: 665996a35ca541c9884453c4c664d197b865bdba888e424cdd8a5a555df96a37
3
+ metadata.gz: 217726d80e8424a761dfa3aa0f565e9cdadd3da409654f5077226e1a85ae29ac
4
+ data.tar.gz: b9ce4a30717b220b78689332da5fe40fe790535029399fa2b0f9978dabd89ff5
5
5
  SHA512:
6
- metadata.gz: 22c1c274df0683053f768b418e7bc239fbc31fce4decf405af2e87c0b60f6f0c365c7dd88ba7a897746fffd785bdf15cfba71549ea8f32a0cdbf054eeb2467fc
7
- data.tar.gz: b8bbe2ec2153c5de43b98bf9e03e80e94d494d056bbb0b65711f20b76fb77b99f5847be4b5420ccce5497ef18fb29dd38fd7f8329b67ce4928dff6d6b43546d4
6
+ metadata.gz: ba13b9a6832ffce17a17548a398ca25f5588b6b7bc24e349151ddb3289173418947f833959fa83497e65d953ce781c51007c8a8516c42a5deab589e2e013100b
7
+ data.tar.gz: a2165e1aa07cdd4f7ca90f6c233dace66f9aba8647f652fb26f8ae484ee142506c218a57c57f1c9d86dc5a551958cd95a01be28de789b817ebd529280ee6c8f3
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,101 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.3.0:
11
+
12
+ 2.3.0 — 2026-02-26
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - ``touch_instances!`` and ``remove_from_instances!`` instance methods for
19
+ explicit instances timeline management. ``touch_instances!`` is idempotent
20
+ (ZADD updates the timestamp without duplicating).
21
+
22
+ - ``in_instances?(identifier)`` class method for O(log N) membership checks
23
+ against the ``instances`` sorted set without loading the object.
24
+
25
+ - Dirty tracking for scalar fields: ``dirty?``, ``dirty_fields``,
26
+ ``changed_fields``, ``clear_dirty!``. Setters automatically mark fields
27
+ dirty; state is cleared after ``save``, ``commit_fields``, and ``refresh!``.
28
+
29
+ - ``warn_if_dirty!`` guard on collection write methods (``add``, ``push``,
30
+ ``[]=``, ``value=``). Warns when the parent Horreum has unsaved scalar
31
+ changes. Enable ``Familia.strict_write_order = true`` to raise instead.
32
+
33
+ - ``ttl_report`` instance method on Expiration-enabled models. Returns a hash
34
+ showing TTL for the main key and all relation keys, useful for detecting
35
+ TTL drift.
36
+
37
+ - ``debug_fields`` instance method on Horreum. Returns a diagnostic hash
38
+ showing Ruby value, stored JSON, and type for each persistent field.
39
+
40
+ - Proactive consistency audit infrastructure for Horreum models. Every
41
+ subclass now has ``health_check``, ``audit_instances``,
42
+ ``audit_unique_indexes``, ``audit_multi_indexes``, and
43
+ ``audit_participations`` class methods to detect phantoms (timeline
44
+ entries without backing keys), missing entries (keys not in timeline),
45
+ stale index entries, and orphaned participation members. Issue #221.
46
+
47
+ - ``AuditReport`` data structure (``Data.define``) that wraps audit
48
+ results with ``healthy?``, ``to_h`` (summary counts), and ``to_s``
49
+ (human-readable) methods for quick inspection and programmatic use.
50
+
51
+ - Repair and rebuild operations: ``repair_instances!``,
52
+ ``rebuild_instances``, ``repair_indexes!``,
53
+ ``repair_participations!``, and ``repair_all!`` class methods.
54
+ ``rebuild_instances`` performs a full SCAN-based rebuild with atomic
55
+ swap via the existing ``RebuildStrategies`` infrastructure.
56
+
57
+ - ``scan_keys`` helper on ManagementMethods for production-safe
58
+ enumeration of keys matching a class pattern via SCAN.
59
+
60
+ - Participation audit reads actual collection contents (not the instances
61
+ timeline) and repairs use TYPE introspection to dispatch the correct
62
+ removal command per collection type.
63
+
64
+ Changed
65
+ -------
66
+
67
+ - ``find_by_dbkey`` and ``find_by_identifier`` are now read-only.
68
+ They no longer call ``cleanup_stale_instance_entry`` as a side effect
69
+ when a key is missing. Ghost cleanup is the explicit responsibility
70
+ of the audit/repair layer or direct caller invocation.
71
+ ``cleanup_stale_instance_entry`` is now a public class method.
72
+
73
+ - Fast writers (``field!``), ``batch_update``, ``batch_fast_write``,
74
+ and ``save_fields`` now clear dirty tracking state after a successful
75
+ database write. Note: this currently clears all dirty flags, even for
76
+ fields that were not part of the partial write. This known limitation
77
+ is documented in ``try/features/dirty_tracking_try.rb`` and will be
78
+ addressed in a future release.
79
+
80
+ Fixed
81
+ -----
82
+
83
+ - ``commit_fields``, ``batch_update``, ``save_fields``, and fast writers now
84
+ touch the ``instances`` sorted set via ``touch_instances!``.
85
+ Previously, only ``save`` updated the timeline, leaving objects created
86
+ through other write paths invisible to ``instances.to_a`` enumeration.
87
+
88
+ - Class-level ``destroy!`` now removes the identifier from the ``instances``
89
+ sorted set, preventing ghost entries after deletion.
90
+
91
+ Documentation
92
+ -------------
93
+
94
+ - Added serialization encoding guide to CLAUDE.md showing how each DataType
95
+ serializes values and what raw Redis output looks like per type.
96
+
97
+ AI Assistance
98
+ -------------
99
+
100
+ - Implementation, test authoring, and iterative debugging performed with
101
+ Claude Opus 4.6 assistance across dirty tracking, write-order guards,
102
+ TTL reporting, debug_fields, audit/repair infrastructure, and 211 test
103
+ cases across 14 audit files.
104
+
10
105
  .. _changelog-2.2.0:
11
106
 
12
107
  2.2.0 — 2026-02-23
data/CLAUDE.md CHANGED
@@ -186,12 +186,96 @@ end
186
186
  - `active: true` (Boolean) stores as `"true"` in Redis and loads back as Boolean `true`
187
187
  - `metadata: {key: "value"}` (Hash) stores as JSON and loads back as Hash with proper types
188
188
 
189
+ **Serialization by Type** (what you see in `redis-cli HGETALL` or `GET`):
190
+
191
+ | Context | Serialize method | Ruby `"UK"` in Redis | Ruby `123` in Redis | Deserialize method |
192
+ |---|---|---|---|---|
193
+ | Horreum `field` | `serialize_value` (JSON) | `"\"UK\""` | `"123"` | `deserialize_value` (JSON parse) |
194
+ | `StringKey` | `.to_s` (raw) | `"UK"` | `"123"` | raw string (no parse) |
195
+ | `JsonStringKey` | JSON dump | `"\"UK\""` | `"123"` | JSON parse |
196
+ | `List/Set/SortedSet/HashKey` values | `serialize_value` (JSON) | `"\"UK\""` | `"123"` | `deserialize_value` (JSON parse) |
197
+
198
+ Key distinction: `StringKey` uses raw `.to_s` serialization (not JSON) to support Redis string operations like `INCR`, `DECR`, and `APPEND`. All other types use JSON encoding. When inspecting raw Redis output, a Horreum string field storing `"UK"` appears as `"\"UK\""` (double-quoted), while a `StringKey` storing `"UK"` appears as `"UK"` (no extra quotes).
199
+
200
+ Use `debug_fields` on a Horreum instance to see Ruby values vs stored JSON side-by-side:
201
+ ```ruby
202
+ user.debug_fields
203
+ # => {"country" => {ruby: "UK", stored: "\"UK\"", type: "String"},
204
+ # "age" => {ruby: 30, stored: "30", type: "Integer"}}
205
+ ```
206
+
189
207
  **Database Key Generation**: Automatic key generation using class name, identifier, and field/type names (aka dbkey). Pattern: `classname:identifier:fieldname`
190
208
 
191
209
  **Memory Efficiency**: Only non-nil values are stored in keystore database to optimize memory usage.
192
210
 
193
211
  **Thread Safety**: Data types are frozen after instantiation to ensure immutability.
194
212
 
213
+ ### Write Model: Deferred vs Immediate
214
+
215
+ Familia has a two-tier write model. Understanding when data hits Redis is critical for avoiding inconsistencies.
216
+
217
+ **Scalar fields** (defined with `field`) use deferred writes:
218
+ - Normal setters (`user.name = "Alice"`) only update the in-memory instance variable. Nothing is written to Redis until `save`, `commit_fields`, or `batch_update` is called.
219
+ - Fast writers (`user.name! "Alice"`) perform an immediate `HSET` on the object's hash key. Use these when you need a single field persisted without a full save cycle.
220
+
221
+ **Collection fields** (defined with `list`, `set`, `zset`, `hashkey`) use immediate writes:
222
+ - Every mutating method (`add`, `push`, `remove`, `clear`, `[]=`) executes the corresponding Redis command (SADD, RPUSH, ZREM, DEL, etc.) right away.
223
+ - Collection fields live on separate Redis keys from the object's hash, so they cannot participate in the same MULTI/EXEC transaction as scalar fields.
224
+
225
+ **Safe pattern -- scalars first, then collections:**
226
+ ```ruby
227
+ plan.name = "Premium"
228
+ plan.region = "US"
229
+ plan.save # HMSET for all scalar fields
230
+
231
+ # Collections mutated AFTER scalars are committed
232
+ plan.features.clear
233
+ plan.features.add("sso")
234
+ plan.features.add("priority_support")
235
+
236
+ # Or use the convenience wrapper:
237
+ plan.save_with_collections do
238
+ plan.features.clear
239
+ plan.features.add("sso")
240
+ end
241
+ ```
242
+
243
+ **Unsafe pattern -- collections before save:**
244
+ ```ruby
245
+ plan.name = "Premium"
246
+ plan.features.clear # Immediate: Redis DEL
247
+ plan.features.add("sso") # Immediate: Redis SADD
248
+ plan.save # If this raises, features are already mutated
249
+ ```
250
+
251
+ **Cross-database limitation**: MULTI/EXEC transactions only work within a single Redis database number. If scalar fields and a collection use different `logical_database` values, they cannot share a transaction. The `save_with_collections` pattern handles this by sequencing the operations rather than wrapping them in MULTI.
252
+
253
+ **Instances timeline**: The class-level `instances` sorted set is a timeline of last-modified times, not a registry. `persist_to_storage` (called by `save`) and `commit_fields`/`batch_update` all call `touch_instances!` to update the timestamp. Use `in_instances?(identifier)` for fast O(log N) checks without loading the object.
254
+
255
+ ### Instances Timeline Lifecycle
256
+
257
+ Every Horreum subclass has a class-level `instances` sorted set (a `class_sorted_set` with `reference: true`). This timeline maps identifiers to their last-write timestamp (ZADD score).
258
+
259
+ **Write paths that touch instances** (call `touch_instances!`):
260
+ - `save` / `save_if_not_exists!` (via `persist_to_storage`)
261
+ - `commit_fields`
262
+ - `batch_update`
263
+ - `save_fields`
264
+ - Fast writers (`field_name!`) via `FieldType` and `DefinitionMethods`
265
+
266
+ **Write paths that remove from instances**:
267
+ - Instance-level `destroy!` (via `remove_from_instances!`)
268
+ - Class-level `destroy!(identifier)` (direct `instances.remove`)
269
+ - `cleanup_stale_instance_entry` in `find_by_dbkey` (lazy, on-access pruning)
270
+
271
+ **Ghost objects**: When a hash key expires via TTL but the identifier still lingers in `instances`, enumerating `instances.to_a` returns identifiers for objects that no longer exist. These are cleaned lazily: `find_by_dbkey` detects the missing key and calls `cleanup_stale_instance_entry`. Code that enumerates without loading (e.g. `instances.members`) will still see ghosts.
272
+
273
+ **`in_instances?` vs `exists?`**:
274
+ - `in_instances?(id)` checks the `instances` sorted set -- fast O(log N), but may return true for expired keys (ghost entries) or false for objects created outside Familia
275
+ - `exists?(id)` checks the actual Redis hash key -- authoritative but requires a round-trip
276
+
277
+ **`load` / `find_by_id` bypasses instances**: These methods read directly from the hash key via HGETALL. They do not consult `instances`. A key can exist in Redis without being in instances (e.g. created by `commit_fields` in an older version), and vice versa.
278
+
195
279
  ## Thread Safety Considerations
196
280
 
197
281
  ### Current Thread Safety Status (as of 2025-10-21)
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.2.0)
4
+ familia (2.3.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (>= 2.4, < 4.0)
7
7
  csv (~> 3.3)
data/README.md CHANGED
@@ -92,7 +92,7 @@ end
92
92
 
93
93
  ```ruby
94
94
  # Create and save
95
- user = User.create(email: 'alice@example.com', name: 'Alice', created_at: Time.now.to_i)
95
+ user = User.create(email: 'alice@example.com', name: 'Alice', created_at: Familia.now)
96
96
 
97
97
  # Find by identifier
98
98
  user = User.load('alice@example.com')
@@ -241,7 +241,7 @@ doc = SecureDocument.new(
241
241
  document_id: 'doc123',
242
242
  owner_id: 'user456',
243
243
  content: 'Sensitive document content',
244
- created_at: Time.now.to_i
244
+ created_at: Familia.now.to_i
245
245
  )
246
246
 
247
247
  doc.save
@@ -551,9 +551,9 @@ class EncryptionMonitor
551
551
  total_time = 0
552
552
 
553
553
  Familia::Encryption.define_singleton_method(:encrypt) do |data, **opts|
554
- start_time = Time.now
554
+ start_time = Familia.now
555
555
  result = original_encrypt.call(data, **opts)
556
- total_time += (Time.now - start_time)
556
+ total_time += (Familia.now - start_time)
557
557
  call_count += 1
558
558
 
559
559
  if call_count % 100 == 0
@@ -813,7 +813,7 @@ class EncryptionHealthCheck
813
813
  end
814
814
 
815
815
  # Test encrypt/decrypt cycle
816
- test_data = "health_check_#{Time.now.to_i}"
816
+ test_data = "health_check_#{Familia.now.to_i}"
817
817
  encrypted = Familia::Encryption.encrypt(test_data)
818
818
  decrypted = Familia::Encryption.decrypt(encrypted)
819
819
  results[:sample_encrypt_decrypt] = (decrypted == test_data)
@@ -262,7 +262,7 @@ class Event < Familia::Horreum
262
262
  end
263
263
  end
264
264
 
265
- today = Time.now.strftime('%Y%m%d')
265
+ today = Familia.now.strftime('%Y%m%d')
266
266
  todays_events = user.find_all_by_daily_partition(today)
267
267
  ```
268
268
 
@@ -175,7 +175,7 @@ active_tasks = project.tasks.range_by_score(1, '+inf')
175
175
  participates_in User, :sessions, score: :expires_at
176
176
 
177
177
  # Query active sessions
178
- now = Time.now.to_i
178
+ now = Familia.now.to_i
179
179
  active = user.sessions.range_by_score(now, '+inf')
180
180
  ```
181
181
 
@@ -91,7 +91,7 @@ class Task < Familia::Horreum
91
91
 
92
92
  # Lambda-based scoring
93
93
  participates_in Sprint, :tasks, score: -> {
94
- priority * 100 + (Time.now - created_at) / 3600
94
+ priority * 100 + (Familia.now - created_at) / 3600
95
95
  }
96
96
  end
97
97
 
@@ -245,7 +245,7 @@ module Familia
245
245
  field: field,
246
246
  old_value: instance_variable_was(field),
247
247
  new_value: instance_variable_get("@#{field}"),
248
- timestamp: Time.now.to_f
248
+ timestamp: Familia.now.to_f
249
249
  }
250
250
 
251
251
  self.class.audit_log.append(change_record.to_json)
@@ -413,7 +413,7 @@ class AuditedFieldType < Familia::FieldType
413
413
 
414
414
  # Audit the change
415
415
  old_value = hget(field_name)
416
- timestamp = Time.now.to_i
416
+ timestamp = Familia.now.to_i
417
417
 
418
418
  # Log the change
419
419
  puts "AUDIT: #{field_name} changed from #{old_value} to #{val} at #{timestamp}"
@@ -677,8 +677,8 @@ describe TimestampFieldType do
677
677
  expect(instance.created_at).to be_a(Time)
678
678
  expect(instance.created_at.to_s).to include("2024-01-01 12:00:00")
679
679
 
680
- instance.created_at = Time.now
681
- expect(instance.created_at).to be_a(Time)
680
+ instance.created_at = Familia.now
681
+ expect(instance.created_at).to be_a(Familia)
682
682
  end
683
683
 
684
684
  it "serializes to integer" do
@@ -466,9 +466,9 @@ domains = Domain.load_multi(domain_ids).compact
466
466
  **Step 3**: Profile the change
467
467
  ```ruby
468
468
  # Add logging temporarily
469
- start = Time.now
469
+ start = Familia.now
470
470
  domains = Domain.load_multi(domain_ids).compact
471
- duration = Time.now - start
471
+ duration = Familia.now - start
472
472
  Rails.logger.info "Loaded #{domains.size} domains in #{duration}s"
473
473
  ```
474
474
 
@@ -232,7 +232,7 @@ class DemoApp
232
232
  session_data = {
233
233
  'user_id' => '12345',
234
234
  'username' => 'demo_user',
235
- 'login_time' => Time.now.to_i,
235
+ 'login_time' => Familia.now.to_i,
236
236
  'preferences' => { 'theme' => 'dark', 'lang' => 'en' },
237
237
  }
238
238
 
@@ -126,7 +126,7 @@ puts 'Example 3: Performance optimization with request caching'
126
126
  entries = []
127
127
 
128
128
  # Without caching - each field derives keys independently
129
- start_time = Time.now
129
+ start_time = Familia.now
130
130
  5.times do |i|
131
131
  private_key_pem = <<~PEM
132
132
  -----BEGIN PRIVATE KEY-----
@@ -145,10 +145,10 @@ start_time = Time.now
145
145
  entry.save
146
146
  entries << entry
147
147
  end
148
- no_cache_time = Time.now - start_time
148
+ no_cache_time = Familia.now - start_time
149
149
 
150
150
  # With caching - reuses derived keys within the block
151
- start_time = Time.now
151
+ start_time = Familia.now
152
152
  Familia::Encryption.with_request_cache do
153
153
  5.times do |i|
154
154
  private_key_pem = <<~PEM
@@ -169,7 +169,7 @@ Familia::Encryption.with_request_cache do
169
169
  entries << entry
170
170
  end
171
171
  end
172
- cached_time = Time.now - start_time
172
+ cached_time = Familia.now - start_time
173
173
 
174
174
  puts "Encryption without caching: #{(no_cache_time * 1000).round(2)}ms"
175
175
  puts "Encryption with caching: #{(cached_time * 1000).round(2)}ms"
@@ -58,7 +58,7 @@ mixed_data = {
58
58
  user: user.as_json,
59
59
  tags: user.tags.as_json,
60
60
  permissions: user.permissions.as_json,
61
- meta: { timestamp: Time.now.to_i }
61
+ meta: { timestamp: Familia.now.to_i }
62
62
  }
63
63
  puts " Manual preparation + JsonSerializer.dump:"
64
64
  puts Familia::JsonSerializer.dump(mixed_data)
@@ -77,7 +77,7 @@ using Familia::Refinements::DearJson
77
77
  mixed_hash = {
78
78
  user: user, # Familia object (will call as_json)
79
79
  tags: user.tags, # Familia DataType (will call as_json)
80
- meta: { timestamp: Time.now.to_i } # Plain hash (passes through)
80
+ meta: { timestamp: Familia.now.to_i } # Plain hash (passes through)
81
81
  }
82
82
 
83
83
  mixed_array = [
@@ -217,7 +217,7 @@ module Familia
217
217
  # obj.pipelined do |conn|
218
218
  # conn.hmset(obj.dbkey, obj.to_h)
219
219
  # conn.hincrby(obj.dbkey, 'count', 1)
220
- # conn.hset(obj.dbkey, 'updated_at', Time.now.to_i)
220
+ # conn.hset(obj.dbkey, 'updated_at', Familia.now.to_i)
221
221
  # end
222
222
  #
223
223
  # @note Connection Inheritance:
@@ -40,7 +40,7 @@ module Familia
40
40
  #
41
41
  # customer.transaction do
42
42
  # customer.increment(:login_count)
43
- # customer.hset(:last_login, Time.now.to_i)
43
+ # customer.hset(:last_login, Familia.now.to_i)
44
44
  # end
45
45
  #
46
46
  # @example Incorrect Pattern: Save Inside Transaction
@@ -15,11 +15,20 @@ module Familia
15
15
  end
16
16
 
17
17
  def increment_if_less_than(threshold, amount = 1)
18
- current = to_i
19
- return false if current >= threshold
20
-
21
- incrementby(amount)
22
- true
18
+ lua = <<~LUA
19
+ local current = tonumber(redis.call('GET', KEYS[1]) or '0')
20
+ if current < tonumber(ARGV[1]) then
21
+ return redis.call('INCRBY', KEYS[1], ARGV[2])
22
+ end
23
+ return nil
24
+ LUA
25
+ result = dbclient.eval(lua, keys: [dbkey], argv: [threshold, amount])
26
+ if result
27
+ update_expiration
28
+ result.to_i
29
+ else
30
+ false
31
+ end
23
32
  end
24
33
 
25
34
  def atomic_increment_and_get(amount = 1)
@@ -19,7 +19,12 @@ module Familia
19
19
 
20
20
  # +return+ [Integer] Returns 1 if the field is new and added, 0 if the
21
21
  # field already existed and the value was updated.
22
+ #
23
+ # @note This method executes a Redis HSET immediately, unlike scalar field
24
+ # setters which are deferred until save. If the parent object has unsaved
25
+ # scalar field changes, consider calling save first to avoid split-brain state.
22
26
  def []=(field, val)
27
+ warn_if_dirty!
23
28
  ret = dbclient.hset dbkey, field.to_s, serialize_value(val)
24
29
  update_expiration
25
30
  ret
@@ -95,13 +100,17 @@ module Familia
95
100
  # @param field [String] The field to remove
96
101
  # @return [Integer] The number of fields that were removed (0 or 1)
97
102
  def remove_field(field)
98
- dbclient.hdel dbkey, field.to_s
103
+ ret = dbclient.hdel dbkey, field.to_s
104
+ update_expiration
105
+ ret
99
106
  end
100
107
  alias remove remove_field
101
108
  alias remove_element remove_field
102
109
 
103
110
  def increment(field, by = 1)
104
- dbclient.hincrby(dbkey, field.to_s, by).to_i
111
+ ret = dbclient.hincrby(dbkey, field.to_s, by).to_i
112
+ update_expiration
113
+ ret
105
114
  end
106
115
  alias incr increment
107
116
  alias incrby increment
@@ -14,7 +14,7 @@ module Familia
14
14
  # class_json_string :last_synced_at, default: 0.0
15
15
  # end
16
16
  #
17
- # MyIndex.last_synced_at = Time.now.to_f # Stored as JSON number
17
+ # MyIndex.last_synced_at = Familia.now.to_f # Stored as JSON number
18
18
  # MyIndex.last_synced_at #=> 1704067200.123 (Float preserved)
19
19
  #
20
20
  # @example Type preservation
@@ -17,7 +17,11 @@ module Familia
17
17
  element_count.zero?
18
18
  end
19
19
 
20
+ # @note This method executes a Redis RPUSH immediately, unlike scalar field
21
+ # setters which are deferred until save. If the parent object has unsaved
22
+ # scalar field changes, consider calling save first to avoid split-brain state.
20
23
  def push *values
24
+ warn_if_dirty!
21
25
  echo :push, Familia.pretty_stack(limit: 1) if Familia.debug
22
26
  values.flatten.compact.each { |v| dbclient.rpush dbkey, serialize_value(v) }
23
27
  dbclient.ltrim dbkey, -@opts[:maxlength], -1 if @opts[:maxlength]
@@ -32,7 +36,11 @@ module Familia
32
36
  alias add_element <<
33
37
  alias add <<
34
38
 
39
+ # @note This method executes a Redis LPUSH immediately, unlike scalar field
40
+ # setters which are deferred until save. If the parent object has unsaved
41
+ # scalar field changes, consider calling save first to avoid split-brain state.
35
42
  def unshift *values
43
+ warn_if_dirty!
36
44
  values.flatten.compact.each { |v| dbclient.lpush dbkey, serialize_value(v) }
37
45
  # TODO: test maxlength
38
46
  dbclient.ltrim dbkey, 0, @opts[:maxlength] - 1 if @opts[:maxlength]
@@ -42,11 +50,15 @@ module Familia
42
50
  alias prepend unshift
43
51
 
44
52
  def pop
45
- deserialize_value dbclient.rpop(dbkey)
53
+ ret = deserialize_value dbclient.rpop(dbkey)
54
+ update_expiration
55
+ ret
46
56
  end
47
57
 
48
58
  def shift
49
- deserialize_value dbclient.lpop(dbkey)
59
+ ret = deserialize_value dbclient.lpop(dbkey)
60
+ update_expiration
61
+ ret
50
62
  end
51
63
 
52
64
  def [](idx, count = nil)
@@ -73,7 +85,9 @@ module Familia
73
85
  # @param count [Integer] Number of elements to remove (0 means all)
74
86
  # @return [Integer] The number of removed elements
75
87
  def remove_element(value, count = 0)
76
- dbclient.lrem dbkey, count, serialize_value(value)
88
+ ret = dbclient.lrem dbkey, count, serialize_value(value)
89
+ update_expiration
90
+ ret
77
91
  end
78
92
  alias remove remove_element
79
93
 
@@ -74,14 +74,14 @@ module Familia
74
74
  # (NX+XX, GT+LT, NX+GT, NX+LT)
75
75
  #
76
76
  # @example Add new element with timestamp
77
- # metrics.add('pageview', Time.now.to_f) #=> true
77
+ # metrics.add('pageview', Familia.now.to_f) #=> true
78
78
  #
79
79
  # @example Preserve original timestamp on subsequent saves
80
- # index.add(email, Time.now.to_f, nx: true) #=> true
81
- # index.add(email, Time.now.to_f, nx: true) #=> false (unchanged)
80
+ # index.add(email, Familia.now.to_f, nx: true) #=> true
81
+ # index.add(email, Familia.now.to_f, nx: true) #=> false (unchanged)
82
82
  #
83
83
  # @example Update timestamp only for existing entries
84
- # index.add(email, Time.now.to_f, xx: true) #=> false (if doesn't exist)
84
+ # index.add(email, Familia.now.to_f, xx: true) #=> false (if doesn't exist)
85
85
  #
86
86
  # @example Only update if new score is higher (leaderboard)
87
87
  # scores.add(player, 1000, gt: true) #=> true (new entry)
@@ -102,7 +102,12 @@ module Familia
102
102
  #
103
103
  # @note INCR option is not supported. Use the increment method for ZINCRBY operations.
104
104
  #
105
+ # @note This method executes a Redis ZADD immediately, unlike scalar field
106
+ # setters which are deferred until save. If the parent object has unsaved
107
+ # scalar field changes, consider calling save first to avoid split-brain state.
108
+ #
105
109
  def add(val, score = nil, nx: false, xx: false, gt: false, lt: false, ch: false)
110
+ warn_if_dirty!
106
111
  score ||= Familia.now
107
112
 
108
113
  # Validate mutual exclusivity
@@ -261,15 +266,21 @@ module Familia
261
266
  end
262
267
 
263
268
  def remrangebyrank(srank, erank)
264
- dbclient.zremrangebyrank dbkey, srank, erank
269
+ ret = dbclient.zremrangebyrank dbkey, srank, erank
270
+ update_expiration
271
+ ret
265
272
  end
266
273
 
267
274
  def remrangebyscore(sscore, escore)
268
- dbclient.zremrangebyscore dbkey, sscore, escore
275
+ ret = dbclient.zremrangebyscore dbkey, sscore, escore
276
+ update_expiration
277
+ ret
269
278
  end
270
279
 
271
280
  def increment(val, by = 1)
272
- dbclient.zincrby(dbkey, by, serialize_value(val)).to_f
281
+ ret = dbclient.zincrby(dbkey, by, serialize_value(val)).to_f
282
+ update_expiration
283
+ ret
273
284
  end
274
285
  alias incr increment
275
286
  alias incrby increment
@@ -285,7 +296,9 @@ module Familia
285
296
  # @return [Integer] The number of members that were removed (0 or 1)
286
297
  def remove_element(value)
287
298
  Familia.trace :REMOVE_ELEMENT, nil, "#{value}<#{value.class}>" if Familia.debug?
288
- dbclient.zrem dbkey, serialize_value(value)
299
+ ret = dbclient.zrem dbkey, serialize_value(value)
300
+ update_expiration
301
+ ret
289
302
  end
290
303
  alias remove remove_element # deprecated
291
304
 
@@ -58,7 +58,11 @@ module Familia
58
58
  value.to_i
59
59
  end
60
60
 
61
+ # @note This method executes a Redis SET immediately, unlike scalar field
62
+ # setters which are deferred until save. If the parent object has unsaved
63
+ # scalar field changes, consider calling save first to avoid split-brain state.
61
64
  def value=(val)
65
+ warn_if_dirty!
62
66
  ret = dbclient.set(dbkey, serialize_value(val))
63
67
  update_expiration
64
68
  ret
@@ -19,7 +19,11 @@ module Familia
19
19
  element_count.zero?
20
20
  end
21
21
 
22
+ # @note This method executes a Redis SADD immediately, unlike scalar field
23
+ # setters which are deferred until save. If the parent object has unsaved
24
+ # scalar field changes, consider calling save first to avoid split-brain state.
22
25
  def add *values
26
+ warn_if_dirty!
23
27
  values.flatten.compact.each { |v| dbclient.sadd? dbkey, serialize_value(v) }
24
28
  update_expiration
25
29
  self
@@ -83,7 +87,9 @@ module Familia
83
87
  # @param value The value to remove from the set
84
88
  # @return [Integer] The number of members that were removed (0 or 1)
85
89
  def remove_element(value)
86
- dbclient.srem dbkey, serialize_value(value)
90
+ ret = dbclient.srem dbkey, serialize_value(value)
91
+ update_expiration
92
+ ret
87
93
  end
88
94
  alias remove remove_element # deprecated
89
95
 
@@ -92,11 +98,15 @@ module Familia
92
98
  end
93
99
 
94
100
  def pop
95
- deserialize_value(dbclient.spop(dbkey))
101
+ ret = deserialize_value(dbclient.spop(dbkey))
102
+ update_expiration
103
+ ret
96
104
  end
97
105
 
98
106
  def move(dstkey, val)
99
- dbclient.smove dbkey, dstkey, serialize_value(val)
107
+ ret = dbclient.smove dbkey, dstkey, serialize_value(val)
108
+ update_expiration
109
+ ret
100
110
  end
101
111
 
102
112
  # Get one or more random members from the set