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.
- checksums.yaml +4 -4
- data/CHANGELOG.rst +95 -0
- data/CLAUDE.md +84 -0
- data/Gemfile.lock +1 -1
- data/README.md +1 -1
- data/docs/guides/feature-encrypted-fields.md +4 -4
- data/docs/guides/feature-relationships-indexing.md +1 -1
- data/docs/guides/feature-relationships-participation.md +1 -1
- data/docs/guides/feature-relationships.md +1 -1
- data/docs/guides/feature-system.md +1 -1
- data/docs/guides/field-system.md +3 -3
- data/docs/guides/optimized-loading.md +2 -2
- data/examples/datatype_standalone.rb +1 -1
- data/examples/encrypted_fields.rb +4 -4
- data/examples/json_usage_patterns.rb +2 -2
- data/lib/familia/connection/behavior.rb +1 -1
- data/lib/familia/connection/transaction_core.rb +1 -1
- data/lib/familia/data_type/types/counter.rb +14 -5
- data/lib/familia/data_type/types/hashkey.rb +11 -2
- data/lib/familia/data_type/types/json_stringkey.rb +1 -1
- data/lib/familia/data_type/types/listkey.rb +17 -3
- data/lib/familia/data_type/types/sorted_set.rb +21 -8
- data/lib/familia/data_type/types/stringkey.rb +4 -0
- data/lib/familia/data_type/types/unsorted_set.rb +13 -3
- data/lib/familia/data_type.rb +123 -1
- data/lib/familia/features/expiration.rb +104 -13
- data/lib/familia/features/relationships/participation.rb +2 -2
- data/lib/familia/field_type.rb +14 -1
- data/lib/familia/horreum/connection.rb +3 -3
- data/lib/familia/horreum/definition.rb +7 -0
- data/lib/familia/horreum/dirty_tracking.rb +109 -0
- data/lib/familia/horreum/management/audit.rb +395 -0
- data/lib/familia/horreum/management/audit_report.rb +104 -0
- data/lib/familia/horreum/management/repair.rb +376 -0
- data/lib/familia/horreum/management.rb +147 -22
- data/lib/familia/horreum/persistence.rb +219 -15
- data/lib/familia/horreum/serialization.rb +34 -0
- data/lib/familia/horreum.rb +6 -0
- data/lib/familia/logging.rb +1 -1
- data/lib/familia/settings.rb +21 -1
- data/lib/familia/thread_safety/monitor.rb +4 -4
- data/lib/familia/version.rb +1 -1
- data/lib/middleware/database_logger.rb +4 -4
- data/try/audit/audit_instances_try.rb +119 -0
- data/try/audit/audit_report_try.rb +182 -0
- data/try/audit/audit_unique_indexes_try.rb +92 -0
- data/try/audit/compound_identifier_try.rb +119 -0
- data/try/audit/health_check_try.rb +101 -0
- data/try/audit/m2_rebuild_batch_try.rb +110 -0
- data/try/audit/m3_multi_index_stub_try.rb +175 -0
- data/try/audit/m5_repair_batched_try.rb +144 -0
- data/try/audit/participation_audit_try.rb +121 -0
- data/try/audit/participation_instance_audit_try.rb +206 -0
- data/try/audit/participation_list_audit_try.rb +187 -0
- data/try/audit/participation_set_audit_try.rb +101 -0
- data/try/audit/rebuild_instances_try.rb +117 -0
- data/try/audit/repair_all_integration_try.rb +150 -0
- data/try/audit/repair_all_try.rb +84 -0
- data/try/audit/repair_indexes_try.rb +77 -0
- data/try/audit/repair_instances_try.rb +94 -0
- data/try/audit/repair_participations_try.rb +182 -0
- data/try/audit/repair_score_correctness_try.rb +139 -0
- data/try/audit/scan_keys_try.rb +66 -0
- data/try/edge_cases/find_by_dbkey_race_condition_try.rb +66 -76
- data/try/features/atomic_counter_try.rb +134 -0
- data/try/features/atomicity_try.rb +138 -0
- data/try/features/count_any_edge_cases_try.rb +4 -4
- data/try/features/dirty_tracking_try.rb +504 -0
- data/try/features/expiration_cascade_try.rb +136 -0
- data/try/features/field_groups_try.rb +2 -2
- data/try/features/ghost_objects_try.rb +124 -0
- data/try/features/instance_registry_try.rb +112 -0
- data/try/features/load_missing_keys_try.rb +112 -0
- data/try/features/registry_methods_try.rb +125 -0
- data/try/features/relationships/participation_commands_verification_spec.rb +6 -7
- data/try/features/relationships/participation_commands_verification_try.rb +3 -3
- data/try/features/relationships/participation_performance_improvements_try.rb +1 -1
- data/try/features/relationships/participation_reverse_index_try.rb +2 -2
- data/try/features/relationships/participation_target_class_resolution_try.rb +2 -2
- data/try/features/safe_dump/safe_dump_advanced_try.rb +1 -1
- data/try/features/save_with_collections_try.rb +75 -0
- data/try/features/serialization_debug_try.rb +159 -0
- data/try/features/ttl_report_try.rb +183 -0
- data/try/integration/connection/transaction_fallback_integration_try.rb +1 -1
- data/try/integration/connection/transaction_mode_permissive_try.rb +1 -1
- data/try/integration/models/customer_try.rb +4 -4
- data/try/integration/relationships_persistence_round_trip_try.rb +1 -1
- data/try/integration/transaction_safety_core_try.rb +1 -1
- data/try/integration/transaction_safety_workflow_try.rb +11 -11
- data/try/migration/integration_try.rb +1 -1
- data/try/migration/model_try.rb +1 -1
- data/try/migration/pipeline_try.rb +2 -2
- data/try/migration/registry_try.rb +1 -1
- data/try/migration/runner_try.rb +1 -1
- data/try/migration/v1_to_v2_serialization_try.rb +2 -2
- data/try/performance/transaction_safety_benchmark_try.rb +20 -20
- data/try/support/benchmarks/deserialization_benchmark.rb +1 -1
- data/try/support/benchmarks/deserialization_correctness_test.rb +1 -1
- data/try/unit/data_types/counter_try.rb +2 -2
- data/try/unit/horreum/serialization_try.rb +4 -4
- data/try/unit/horreum/unique_index_guard_validation_try.rb +1 -1
- metadata +36 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 217726d80e8424a761dfa3aa0f565e9cdadd3da409654f5077226e1a85ae29ac
|
|
4
|
+
data.tar.gz: b9ce4a30717b220b78689332da5fe40fe790535029399fa2b0f9978dabd89ff5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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:
|
|
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:
|
|
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 =
|
|
554
|
+
start_time = Familia.now
|
|
555
555
|
result = original_encrypt.call(data, **opts)
|
|
556
|
-
total_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_#{
|
|
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)
|
|
@@ -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 =
|
|
178
|
+
now = Familia.now.to_i
|
|
179
179
|
active = user.sessions.range_by_score(now, '+inf')
|
|
180
180
|
```
|
|
181
181
|
|
|
@@ -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:
|
|
248
|
+
timestamp: Familia.now.to_f
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
self.class.audit_log.append(change_record.to_json)
|
data/docs/guides/field-system.md
CHANGED
|
@@ -413,7 +413,7 @@ class AuditedFieldType < Familia::FieldType
|
|
|
413
413
|
|
|
414
414
|
# Audit the change
|
|
415
415
|
old_value = hget(field_name)
|
|
416
|
-
timestamp =
|
|
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 =
|
|
681
|
-
expect(instance.created_at).to be_a(
|
|
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 =
|
|
469
|
+
start = Familia.now
|
|
470
470
|
domains = Domain.load_multi(domain_ids).compact
|
|
471
|
-
duration =
|
|
471
|
+
duration = Familia.now - start
|
|
472
472
|
Rails.logger.info "Loaded #{domains.size} domains in #{duration}s"
|
|
473
473
|
```
|
|
474
474
|
|
|
@@ -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 =
|
|
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 =
|
|
148
|
+
no_cache_time = Familia.now - start_time
|
|
149
149
|
|
|
150
150
|
# With caching - reuses derived keys within the block
|
|
151
|
-
start_time =
|
|
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 =
|
|
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:
|
|
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:
|
|
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',
|
|
220
|
+
# conn.hset(obj.dbkey, 'updated_at', Familia.now.to_i)
|
|
221
221
|
# end
|
|
222
222
|
#
|
|
223
223
|
# @note Connection Inheritance:
|
|
@@ -15,11 +15,20 @@ module Familia
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def increment_if_less_than(threshold, amount = 1)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 =
|
|
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',
|
|
77
|
+
# metrics.add('pageview', Familia.now.to_f) #=> true
|
|
78
78
|
#
|
|
79
79
|
# @example Preserve original timestamp on subsequent saves
|
|
80
|
-
# index.add(email,
|
|
81
|
-
# index.add(email,
|
|
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,
|
|
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
|