familia 2.11.1 → 2.11.2
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/.github/workflows/ci.yml +1 -1
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/CHANGELOG.rst +34 -0
- data/Gemfile.lock +3 -3
- data/familia.gemspec +1 -1
- data/lib/familia/horreum/database_commands.rb +7 -1
- data/lib/familia/horreum/persistence.rb +114 -19
- data/lib/familia/horreum/serialization.rb +16 -2
- data/lib/familia/version.rb +1 -1
- data/try/features/encrypted_fields/re_encrypt_fields_try.rb +7 -7
- data/try/integration/database_consistency_try.rb +4 -3
- data/try/integration/nil_field_absence_try.rb +160 -0
- data/try/unit/horreum/serialization_try.rb +6 -3
- metadata +10 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7b0eed1c3632c68c8c4c91617830f0b15a10907da5c7af0bbc14b9cf67a5336
|
|
4
|
+
data.tar.gz: 4a123b805e826b8ede218675c16781f428fab6c3194968c5a3840323b949b28e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6c245370d354eaa0d8af0b0994cd44ea10f8dec3b260c9750d3cceb66563c51ee8f4390062429c8aced6cd578d919c7a806a0eef2e5be0f9d90bbc00dcf87b06
|
|
7
|
+
data.tar.gz: a824edcd1af5d3535168600d90a3e189b0203c884fe17c7382cafb22ebc6c19a36c68011a468e9dd8b16fed386b902eac16709625004f73b7f4085b0ae0a601e
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -53,7 +53,7 @@ jobs:
|
|
|
53
53
|
bundler-cache: true
|
|
54
54
|
|
|
55
55
|
- name: Setup tmate session
|
|
56
|
-
uses: mxschmitt/action-tmate@
|
|
56
|
+
uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3
|
|
57
57
|
if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }}
|
|
58
58
|
with:
|
|
59
59
|
detached: true
|
|
@@ -158,4 +158,4 @@ jobs:
|
|
|
158
158
|
echo "Releasing familia ${gem_version} from tag ${RELEASE_TAG}"
|
|
159
159
|
|
|
160
160
|
- name: Build and push gem to RubyGems
|
|
161
|
-
uses: rubygems/release-gem@
|
|
161
|
+
uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0
|
|
@@ -49,7 +49,7 @@ jobs:
|
|
|
49
49
|
bundler-cache: true
|
|
50
50
|
|
|
51
51
|
- name: Setup tmate session
|
|
52
|
-
uses: mxschmitt/action-tmate@
|
|
52
|
+
uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3
|
|
53
53
|
if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }}
|
|
54
54
|
with:
|
|
55
55
|
detached: true
|
data/CHANGELOG.rst
CHANGED
|
@@ -7,6 +7,40 @@ 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.11.2:
|
|
11
|
+
|
|
12
|
+
2.11.2 — 2026-07-05
|
|
13
|
+
===================
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
Changed
|
|
17
|
+
-------
|
|
18
|
+
|
|
19
|
+
- ``to_h_for_storage`` omits nil fields; every write path removes a field that has
|
|
20
|
+
become nil. ``save``/``commit_fields`` do a full overwrite (the stored hash
|
|
21
|
+
matches the in-memory object, so a field nil in memory is deleted);
|
|
22
|
+
``save_fields``/``multi_field_update``/``multi_field_fast_write`` delete a named
|
|
23
|
+
field passed as nil. ``to_h`` still returns every declared field, nils included.
|
|
24
|
+
|
|
25
|
+
- Claim caveat: a full ``save``/``commit_fields`` of a stale copy clears a field
|
|
26
|
+
another writer claimed via ``HSETNX``. To claim and update without disturbing
|
|
27
|
+
it, use the targeted writers or ``refresh!`` first.
|
|
28
|
+
|
|
29
|
+
Fixed
|
|
30
|
+
-----
|
|
31
|
+
|
|
32
|
+
- Nil-valued fields are no longer stored as the JSON string ``"null"``. Because a
|
|
33
|
+
hash has no native NULL, this left declared fields perpetually present, breaking
|
|
34
|
+
``HSETNX``/``HEXISTS`` atomic-claim patterns and wasting memory. Nil fields are
|
|
35
|
+
now omitted, and clearing a field to nil removes it from storage (``HDEL``), so
|
|
36
|
+
absence again means "no value". No migration required: stale ``"null"`` values
|
|
37
|
+
are cleaned up on the next save and already decode back to ``nil`` on read.
|
|
38
|
+
|
|
39
|
+
AI Assistance
|
|
40
|
+
-------------
|
|
41
|
+
|
|
42
|
+
- Investigation, implementation, and tryouts coverage produced with Claude Code.
|
|
43
|
+
|
|
10
44
|
.. _changelog-2.11.1:
|
|
11
45
|
|
|
12
46
|
2.11.1 — 2026-07-04
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
familia (2.11.
|
|
4
|
+
familia (2.11.2)
|
|
5
5
|
concurrent-ruby (~> 1.3)
|
|
6
6
|
connection_pool (>= 2.4, < 4.0)
|
|
7
7
|
csv (~> 3.3)
|
|
@@ -9,7 +9,7 @@ PATH
|
|
|
9
9
|
logger (~> 1.7)
|
|
10
10
|
oj (~> 3.16)
|
|
11
11
|
redis (>= 5.0, < 6.0)
|
|
12
|
-
stringio (
|
|
12
|
+
stringio (>= 3.1.1, < 3.3.0)
|
|
13
13
|
uri-valkey (~> 1.4)
|
|
14
14
|
|
|
15
15
|
GEM
|
|
@@ -166,7 +166,7 @@ GEM
|
|
|
166
166
|
ruby-progressbar (1.13.0)
|
|
167
167
|
simpleidn (0.2.3)
|
|
168
168
|
stackprof (0.2.28)
|
|
169
|
-
stringio (3.
|
|
169
|
+
stringio (3.2.0)
|
|
170
170
|
timecop (0.9.11)
|
|
171
171
|
tryouts (3.7.1)
|
|
172
172
|
concurrent-ruby (~> 1.0, < 2)
|
data/familia.gemspec
CHANGED
|
@@ -26,7 +26,7 @@ Gem::Specification.new do |spec|
|
|
|
26
26
|
spec.add_dependency 'logger', '~> 1.7'
|
|
27
27
|
spec.add_dependency 'oj', '~> 3.16'
|
|
28
28
|
spec.add_dependency 'redis', '>= 5.0', '< 6.0'
|
|
29
|
-
spec.add_dependency 'stringio', '
|
|
29
|
+
spec.add_dependency 'stringio', '>= 3.1.1', '< 3.3.0'
|
|
30
30
|
spec.add_dependency 'uri-valkey', '~> 1.4'
|
|
31
31
|
|
|
32
32
|
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
@@ -157,8 +157,14 @@ module Familia
|
|
|
157
157
|
#
|
|
158
158
|
# @param hsh [Hash] Hash of field-value pairs to set
|
|
159
159
|
# @return [String] 'OK' on success
|
|
160
|
-
def hmset(hsh =
|
|
160
|
+
def hmset(hsh = nil)
|
|
161
161
|
hsh ||= to_h_for_storage
|
|
162
|
+
# An empty hash means there is nothing to persist -- e.g. an object whose
|
|
163
|
+
# only non-nil field would be a Proc-derived identifier, so no declared
|
|
164
|
+
# field is written. HMSET with zero field/value pairs is a Redis error,
|
|
165
|
+
# so treat it as a successful no-op rather than crashing the save.
|
|
166
|
+
return 'OK' if hsh.empty?
|
|
167
|
+
|
|
162
168
|
Familia.trace :HMSET, nil, hsh if Familia.debug?
|
|
163
169
|
dbclient.hmset dbkey(suffix), hsh
|
|
164
170
|
end
|
|
@@ -88,6 +88,15 @@ module Familia
|
|
|
88
88
|
# @example Single-expression short-circuit
|
|
89
89
|
# user.save && AuditLog.record(:user_updated, user.identifier)
|
|
90
90
|
#
|
|
91
|
+
# @note This is a FULL-OVERWRITE of the object's scalar state: afterwards
|
|
92
|
+
# the stored hash matches the in-memory object exactly. Non-nil fields are
|
|
93
|
+
# written and fields that are nil in memory are removed from storage. A
|
|
94
|
+
# field managed out of band -- e.g. one claimed by another actor via
|
|
95
|
+
# HSETNX while this (possibly stale) copy still holds nil for it -- is
|
|
96
|
+
# therefore cleared by a full save. To update an object without disturbing
|
|
97
|
+
# such fields, use the targeted writers ({#save_fields},
|
|
98
|
+
# {#multi_field_update}, {#multi_field_fast_write}) or {#refresh!} first.
|
|
99
|
+
#
|
|
91
100
|
# @see #save_if_not_exists! For conditional saves
|
|
92
101
|
# @see #transaction For atomic operations after save
|
|
93
102
|
#
|
|
@@ -319,17 +328,24 @@ module Familia
|
|
|
319
328
|
Familia.debug "[commit_fields] Begin #{self.class} #{dbkey} #{prepared_value} (exp: #{update_expiration})"
|
|
320
329
|
|
|
321
330
|
result = transaction do |_conn|
|
|
322
|
-
# Set all fields atomically
|
|
323
|
-
|
|
331
|
+
# Set all non-nil fields atomically
|
|
332
|
+
hmset_result = hmset(prepared_value)
|
|
333
|
+
|
|
334
|
+
# Remove any fields cleared to nil so their prior stored value is not
|
|
335
|
+
# left stale (HMSET never deletes omitted fields).
|
|
336
|
+
remove_stale_nil_fields
|
|
324
337
|
|
|
325
338
|
# Update expiration in same transaction to ensure atomicity
|
|
326
|
-
self.update_expiration if
|
|
339
|
+
self.update_expiration if hmset_result && update_expiration
|
|
327
340
|
|
|
328
|
-
# Touch instances timeline so the object is visible
|
|
329
|
-
#
|
|
330
|
-
|
|
341
|
+
# Touch instances timeline so the object is visible to list-based
|
|
342
|
+
# enumeration (instances.to_a, count, etc.). Skip it when nothing was
|
|
343
|
+
# persisted and no hash key exists -- otherwise the identifier is
|
|
344
|
+
# registered in `instances` pointing at a missing hash (see
|
|
345
|
+
# {#persist_to_storage}).
|
|
346
|
+
touch_instances! if hmset_result && !prepared_value.empty?
|
|
331
347
|
|
|
332
|
-
|
|
348
|
+
hmset_result
|
|
333
349
|
end
|
|
334
350
|
|
|
335
351
|
# Clear dirty tracking after successful commit
|
|
@@ -376,10 +392,15 @@ module Familia
|
|
|
376
392
|
Familia.trace :MULTI_FIELD_UPDATE, nil, fields.keys if Familia.debug?
|
|
377
393
|
|
|
378
394
|
result = transaction do |_conn|
|
|
379
|
-
# 1. Update all fields atomically (Redis only, no in-memory mutation)
|
|
395
|
+
# 1. Update all fields atomically (Redis only, no in-memory mutation).
|
|
396
|
+
# A nil value deletes the field rather than storing "null", so absence
|
|
397
|
+
# stays authoritative (see Serialization#to_h_for_storage).
|
|
380
398
|
fields.each do |field, value|
|
|
381
|
-
|
|
382
|
-
|
|
399
|
+
if value.nil?
|
|
400
|
+
remove_field(field)
|
|
401
|
+
else
|
|
402
|
+
hset field, serialize_value(value)
|
|
403
|
+
end
|
|
383
404
|
end
|
|
384
405
|
|
|
385
406
|
# 2. Update expiration in same transaction
|
|
@@ -434,15 +455,24 @@ module Familia
|
|
|
434
455
|
|
|
435
456
|
Familia.trace :MULTI_FIELD_FAST_WRITE, nil, fields.keys if Familia.debug?
|
|
436
457
|
|
|
437
|
-
# Serialize values before the transaction (read-only on instance)
|
|
458
|
+
# Serialize values before the transaction (read-only on instance).
|
|
459
|
+
# A nil value deletes the field rather than storing "null" (see
|
|
460
|
+
# Serialization#to_h_for_storage), so split writes from removals.
|
|
438
461
|
serialized = {}
|
|
462
|
+
nil_fields = []
|
|
439
463
|
fields.each do |field, value|
|
|
440
|
-
|
|
464
|
+
if value.nil?
|
|
465
|
+
nil_fields << field.to_s
|
|
466
|
+
else
|
|
467
|
+
serialized[field] = serialize_value(value)
|
|
468
|
+
end
|
|
441
469
|
end
|
|
442
470
|
|
|
443
471
|
result = transaction do |_conn|
|
|
444
472
|
hmset(serialized)
|
|
445
473
|
|
|
474
|
+
dbclient.hdel(dbkey, *nil_fields) unless nil_fields.empty?
|
|
475
|
+
|
|
446
476
|
update_expiration if update_exp
|
|
447
477
|
|
|
448
478
|
touch_instances!
|
|
@@ -478,20 +508,29 @@ module Familia
|
|
|
478
508
|
Familia.trace :SAVE_FIELDS, nil, field_names if Familia.debug?
|
|
479
509
|
|
|
480
510
|
result = transaction do |_conn|
|
|
481
|
-
# Build hash of field
|
|
511
|
+
# Build hash of non-nil field values; collect nil'd fields for removal.
|
|
512
|
+
# A nil field is deleted rather than stored as "null" so that absence
|
|
513
|
+
# stays authoritative (see Serialization#to_h_for_storage).
|
|
482
514
|
fields_hash = {}
|
|
515
|
+
nil_fields = []
|
|
483
516
|
field_names.each do |field|
|
|
484
517
|
field_sym = field.to_sym
|
|
485
518
|
raise ArgumentError, "Unknown field: #{field}" unless respond_to?(field_sym)
|
|
486
519
|
|
|
487
520
|
value = send(field_sym)
|
|
488
|
-
|
|
489
|
-
|
|
521
|
+
if value.nil?
|
|
522
|
+
nil_fields << field.to_s
|
|
523
|
+
else
|
|
524
|
+
fields_hash[field] = serialize_value(value)
|
|
525
|
+
end
|
|
490
526
|
end
|
|
491
527
|
|
|
492
|
-
# Set all fields at once
|
|
528
|
+
# Set all non-nil fields at once (hmset no-ops on an empty hash)
|
|
493
529
|
hmset(fields_hash)
|
|
494
530
|
|
|
531
|
+
# Remove any nil'd fields so their prior stored value does not linger
|
|
532
|
+
dbclient.hdel(dbkey, *nil_fields) unless nil_fields.empty?
|
|
533
|
+
|
|
495
534
|
# Update expiration in same transaction
|
|
496
535
|
self.update_expiration if update_expiration
|
|
497
536
|
|
|
@@ -959,6 +998,46 @@ module Familia
|
|
|
959
998
|
end
|
|
960
999
|
private :prepare_for_save
|
|
961
1000
|
|
|
1001
|
+
# Names (as strings) of declared persistent fields whose current in-memory
|
|
1002
|
+
# value is nil. These are omitted from {Serialization#to_h_for_storage}, so
|
|
1003
|
+
# on an update their previously-stored value must be explicitly deleted.
|
|
1004
|
+
#
|
|
1005
|
+
# @return [Array<String>]
|
|
1006
|
+
#
|
|
1007
|
+
def nil_persistent_field_names
|
|
1008
|
+
self.class.persistent_fields.each_with_object([]) do |field, names|
|
|
1009
|
+
field_type = self.class.field_types[field]
|
|
1010
|
+
names << field.to_s if send(field_type.method_name).nil?
|
|
1011
|
+
end
|
|
1012
|
+
end
|
|
1013
|
+
private :nil_persistent_field_names
|
|
1014
|
+
|
|
1015
|
+
# Deletes any now-nil persistent fields from the object hash so a value
|
|
1016
|
+
# cleared to nil does not leave a stale entry behind. This is what keeps
|
|
1017
|
+
# "absent" and "nil" the same observable state after a round trip, and
|
|
1018
|
+
# what makes HSETNX/HEXISTS on a nil'd field behave correctly.
|
|
1019
|
+
#
|
|
1020
|
+
# Because it removes every field that is nil in memory, save/commit_fields
|
|
1021
|
+
# are a full-overwrite of scalar state (see {#save}). A field managed out of
|
|
1022
|
+
# band (e.g. an HSETNX claim) that is still nil on this in-memory copy is
|
|
1023
|
+
# cleared by a full save; use the targeted writers or {#refresh!} to avoid
|
|
1024
|
+
# that.
|
|
1025
|
+
#
|
|
1026
|
+
# Intended to run inside the save/commit transaction. HDEL of an absent
|
|
1027
|
+
# field is a harmless no-op, so it is safe on both the create and update
|
|
1028
|
+
# paths (and queues cleanly inside MULTI/EXEC).
|
|
1029
|
+
#
|
|
1030
|
+
# @return [void]
|
|
1031
|
+
#
|
|
1032
|
+
def remove_stale_nil_fields
|
|
1033
|
+
names = nil_persistent_field_names
|
|
1034
|
+
return if names.empty?
|
|
1035
|
+
|
|
1036
|
+
Familia.trace :HDEL, nil, names if Familia.debug?
|
|
1037
|
+
dbclient.hdel(dbkey, *names)
|
|
1038
|
+
end
|
|
1039
|
+
private :remove_stale_nil_fields
|
|
1040
|
+
|
|
962
1041
|
# Persists the object's data to storage within a transaction.
|
|
963
1042
|
#
|
|
964
1043
|
# This is the primary code path that adds an object to the class-level
|
|
@@ -975,18 +1054,34 @@ module Familia
|
|
|
975
1054
|
# @return [Object] The result of the hmset operation
|
|
976
1055
|
#
|
|
977
1056
|
def persist_to_storage(update_expiration)
|
|
978
|
-
# 1. Save all fields to hashkey at once
|
|
1057
|
+
# 1. Save all non-nil fields to hashkey at once
|
|
979
1058
|
prepared_h = to_h_for_storage
|
|
980
1059
|
hmset_result = hmset(prepared_h)
|
|
981
1060
|
|
|
1061
|
+
# 1b. Remove any fields cleared to nil so a nil'd value does not linger
|
|
1062
|
+
# in storage. HMSET only sets the fields it is given; it never deletes
|
|
1063
|
+
# omitted ones, so an update that clears a field to nil would otherwise
|
|
1064
|
+
# leave the prior value behind. Harmless no-op on the create path.
|
|
1065
|
+
remove_stale_nil_fields
|
|
1066
|
+
|
|
982
1067
|
# 2. Set expiration in same transaction
|
|
983
1068
|
self.update_expiration if update_expiration
|
|
984
1069
|
|
|
985
1070
|
# 3. Update class-level indexes
|
|
986
1071
|
auto_update_class_indexes
|
|
987
1072
|
|
|
988
|
-
# 4. Touch instances timeline (delegates to touch_instances!)
|
|
989
|
-
|
|
1073
|
+
# 4. Touch instances timeline (delegates to touch_instances!).
|
|
1074
|
+
#
|
|
1075
|
+
# Skip it when there is genuinely nothing stored and no hash key exists.
|
|
1076
|
+
# prepared_h is empty only when every declared persistent field is nil
|
|
1077
|
+
# AND the identifier is not itself a stored field (a Proc-derived
|
|
1078
|
+
# identifier) -- hmset no-op'd and no key was created. Registering the
|
|
1079
|
+
# identifier in `instances` in that case would leave a dangling
|
|
1080
|
+
# reference: the identifier would list in instances.to_a while exists?
|
|
1081
|
+
# (check_size: true) returns false and any load follows a dead pointer.
|
|
1082
|
+
# In the ubiquitous `identifier_field :id; field :id` pattern the id
|
|
1083
|
+
# keeps prepared_h non-empty, so this only skips the degenerate case.
|
|
1084
|
+
touch_instances! unless prepared_h.empty?
|
|
990
1085
|
|
|
991
1086
|
hmset_result
|
|
992
1087
|
end
|
|
@@ -22,7 +22,9 @@ module Familia
|
|
|
22
22
|
# # Note: Returns actual Ruby types, not JSON strings
|
|
23
23
|
#
|
|
24
24
|
# @note Only loggable fields are included. Encrypted fields are excluded.
|
|
25
|
-
# @note
|
|
25
|
+
# @note Every persistent field appears, including those whose value is nil.
|
|
26
|
+
# This differs from {#to_h_for_storage}, which omits nil fields so that
|
|
27
|
+
# absence -- not a stored "null" -- represents "no value" in Valkey/Redis.
|
|
26
28
|
#
|
|
27
29
|
def to_h
|
|
28
30
|
self.class.persistent_fields.each_with_object({}) do |field, hsh|
|
|
@@ -55,13 +57,25 @@ module Familia
|
|
|
55
57
|
# # Note: Strings are JSON-encoded with quotes
|
|
56
58
|
#
|
|
57
59
|
# @note This is an internal method used by commit_fields and hmset
|
|
58
|
-
# @note Nil
|
|
60
|
+
# @note Nil-valued fields are omitted so that field *absence* -- not a
|
|
61
|
+
# stored "null" -- represents "no value" in the Valkey/Redis hash. This
|
|
62
|
+
# is what keeps HSETNX/HEXISTS-based claim patterns usable and avoids
|
|
63
|
+
# materializing every declared field. Fields cleared to nil are actively
|
|
64
|
+
# removed from storage on save; see {Persistence#remove_stale_nil_fields}.
|
|
59
65
|
#
|
|
60
66
|
def to_h_for_storage
|
|
61
67
|
self.class.persistent_fields.each_with_object({}) do |field, hsh|
|
|
62
68
|
field_type = self.class.field_types[field]
|
|
63
69
|
|
|
64
70
|
val = send(field_type.method_name)
|
|
71
|
+
|
|
72
|
+
# Omit nil fields entirely. A Valkey/Redis hash has no NULL: absence is
|
|
73
|
+
# the native "no value". Persisting nil as the JSON string "null" would
|
|
74
|
+
# make declared fields perpetually present, defeating HSETNX/HEXISTS and
|
|
75
|
+
# wasting memory. Round-trips are unaffected -- deserialize_value already
|
|
76
|
+
# returns nil for an absent field.
|
|
77
|
+
next if val.nil?
|
|
78
|
+
|
|
65
79
|
prepared = serialize_value(val)
|
|
66
80
|
|
|
67
81
|
if Familia.debug?
|
data/lib/familia/version.rb
CHANGED
|
@@ -127,13 +127,13 @@ result = @partial_fresh.re_encrypt_fields!
|
|
|
127
127
|
|
|
128
128
|
raw_token = Familia.dbclient.hget(@partial_fresh.dbkey, 'token')
|
|
129
129
|
raw_secret = Familia.dbclient.hget(@partial_fresh.dbkey, 'secret')
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
[result,
|
|
136
|
-
#=> [true,
|
|
130
|
+
# A nil field is not persisted at all -- in a Redis hash, absence *is* the
|
|
131
|
+
# representation of "no value" -- so the token field simply does not exist in
|
|
132
|
+
# storage (hget returns nil). The contract we care about here is: re_encrypt_fields!
|
|
133
|
+
# must not turn a nil field into ciphertext; an absent field trivially satisfies
|
|
134
|
+
# that, and is now directly observable via HEXISTS/hget being nil.
|
|
135
|
+
[result, raw_token.nil?, JSON.parse(raw_secret)['key_version']]
|
|
136
|
+
#=> [true, true, 'v2']
|
|
137
137
|
|
|
138
138
|
## is idempotent when current key is unchanged
|
|
139
139
|
# Calling re_encrypt_fields! twice without rotating must succeed both times
|
|
@@ -67,10 +67,11 @@ key_parts = dbkey.split(':')
|
|
|
67
67
|
expected_fields = @serial_test.class.persistent_fields.length
|
|
68
68
|
redis_field_count = Familia.dbclient.hlen(@serial_test.dbkey)
|
|
69
69
|
actual_object_fields = @serial_test.to_h.keys.length
|
|
70
|
-
#
|
|
71
|
-
#
|
|
70
|
+
# Storage omits nil fields (absence represents "no value"), so only the single
|
|
71
|
+
# non-nil field (id) is written even though the class declares 5 persistent
|
|
72
|
+
# fields. to_h still reports every declared field, including the nil ones.
|
|
72
73
|
[expected_fields, redis_field_count, actual_object_fields]
|
|
73
|
-
#=> [5,
|
|
74
|
+
#=> [5, 1, 5]
|
|
74
75
|
|
|
75
76
|
## Memory vs persistence state consistency after save
|
|
76
77
|
@consistency_obj = ConsistencyTestModel.new(id: next_test_id, name: 'Memory Test', email: 'test@example.com')
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# try/integration/nil_field_absence_try.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
#
|
|
5
|
+
# Nil-valued declared fields are ABSENT from the Valkey/Redis hash rather than
|
|
6
|
+
# stored as the JSON literal "null". Field absence is the native representation
|
|
7
|
+
# of "no value" in a hash, and it is what makes HSETNX/HEXISTS-based claim
|
|
8
|
+
# patterns work and keeps "unset" and "nil" the same observable state after a
|
|
9
|
+
# round trip.
|
|
10
|
+
|
|
11
|
+
require_relative '../support/helpers/test_helpers'
|
|
12
|
+
|
|
13
|
+
class NilAbsenceModel < Familia::Horreum
|
|
14
|
+
identifier_field :id
|
|
15
|
+
field :id
|
|
16
|
+
field :name
|
|
17
|
+
field :claimed_at # nil until atomically claimed via HSETNX
|
|
18
|
+
field :note
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Identifier derived from a NON-persistent attribute (a Proc), so every declared
|
|
22
|
+
# persistent field can be nil while the object still has a usable identifier.
|
|
23
|
+
# This exercises the degenerate "nothing to persist" path.
|
|
24
|
+
class ProcIdAbsenceModel < Familia::Horreum
|
|
25
|
+
identifier_field ->(obj) { obj.probe_id }
|
|
26
|
+
field :name
|
|
27
|
+
field :note
|
|
28
|
+
|
|
29
|
+
attr_accessor :probe_id
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Clean up any prior test data
|
|
33
|
+
begin
|
|
34
|
+
existing = Familia.dbclient.keys('nilabsencemodel:*')
|
|
35
|
+
Familia.dbclient.del(*existing) if existing.any?
|
|
36
|
+
rescue StandardError
|
|
37
|
+
# ignore cleanup errors
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@id_counter = 0
|
|
41
|
+
def next_id
|
|
42
|
+
@id_counter += 1
|
|
43
|
+
"nil-absence-#{Familia.now.to_i}-#{@id_counter}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
## A nil declared field is not written to storage (HEXISTS is false)
|
|
47
|
+
@obj = NilAbsenceModel.new(id: next_id, name: 'has name')
|
|
48
|
+
@obj.save
|
|
49
|
+
Familia.dbclient.hexists(@obj.dbkey, 'claimed_at')
|
|
50
|
+
#=> false
|
|
51
|
+
|
|
52
|
+
## Only the non-nil fields are stored in the hash
|
|
53
|
+
@obj.hgetall.keys.sort
|
|
54
|
+
#=> ["id", "name"]
|
|
55
|
+
|
|
56
|
+
## to_h still reports every declared field, including the nil ones
|
|
57
|
+
@obj.to_h.keys.sort
|
|
58
|
+
#=> ["claimed_at", "id", "name", "note"]
|
|
59
|
+
|
|
60
|
+
## The nil field round-trips as nil after refresh (absent == nil)
|
|
61
|
+
@obj.refresh!
|
|
62
|
+
@obj.claimed_at
|
|
63
|
+
#=> nil
|
|
64
|
+
|
|
65
|
+
## HSETNX succeeds on a nil declared field -- the field is genuinely absent
|
|
66
|
+
@claim = NilAbsenceModel.new(id: next_id, name: 'claimant')
|
|
67
|
+
@claim.save
|
|
68
|
+
@claim.hsetnx('claimed_at', Familia.now.to_i.to_s)
|
|
69
|
+
#=> true
|
|
70
|
+
|
|
71
|
+
## A second HSETNX fails: the field now holds a value (first-writer-wins)
|
|
72
|
+
@claim.hsetnx('claimed_at', Familia.now.to_i.to_s)
|
|
73
|
+
#=> false
|
|
74
|
+
|
|
75
|
+
## Clearing a previously-set field to nil and saving REMOVES it from storage
|
|
76
|
+
@edit = NilAbsenceModel.new(id: next_id, name: 'edit me', note: 'temporary')
|
|
77
|
+
@edit.save
|
|
78
|
+
@edit.note = nil
|
|
79
|
+
@edit.save
|
|
80
|
+
Familia.dbclient.hexists(@edit.dbkey, 'note')
|
|
81
|
+
#=> false
|
|
82
|
+
|
|
83
|
+
## After clearing, HSETNX can claim the freed field again
|
|
84
|
+
@edit.hsetnx('note', 'reclaimed')
|
|
85
|
+
#=> true
|
|
86
|
+
|
|
87
|
+
## An object whose only non-nil field is its identifier still exists
|
|
88
|
+
@bare = NilAbsenceModel.new(id: next_id)
|
|
89
|
+
@bare.save
|
|
90
|
+
@bare.exists?
|
|
91
|
+
#=> true
|
|
92
|
+
|
|
93
|
+
## commit_fields also removes a field cleared to nil
|
|
94
|
+
@commit = NilAbsenceModel.new(id: next_id, name: 'keep', note: 'drop me')
|
|
95
|
+
@commit.save
|
|
96
|
+
@commit.note = nil
|
|
97
|
+
@commit.commit_fields
|
|
98
|
+
Familia.dbclient.hexists(@commit.dbkey, 'note')
|
|
99
|
+
#=> false
|
|
100
|
+
|
|
101
|
+
## multi_field_update with a nil value deletes the field
|
|
102
|
+
@mfu = NilAbsenceModel.new(id: next_id, name: 'mfu', note: 'present')
|
|
103
|
+
@mfu.save
|
|
104
|
+
@mfu.multi_field_update(note: nil)
|
|
105
|
+
Familia.dbclient.hexists(@mfu.dbkey, 'note')
|
|
106
|
+
#=> false
|
|
107
|
+
|
|
108
|
+
## save_fields with a nil value deletes the named field
|
|
109
|
+
@sf = NilAbsenceModel.new(id: next_id, name: 'sf', note: 'present')
|
|
110
|
+
@sf.save
|
|
111
|
+
@sf.note = nil
|
|
112
|
+
@sf.save_fields(:note)
|
|
113
|
+
Familia.dbclient.hexists(@sf.dbkey, 'note')
|
|
114
|
+
#=> false
|
|
115
|
+
|
|
116
|
+
## multi_field_fast_write with a nil value deletes the named field
|
|
117
|
+
@mffw = NilAbsenceModel.new(id: next_id, name: 'mffw', note: 'present')
|
|
118
|
+
@mffw.save
|
|
119
|
+
@mffw.multi_field_fast_write(note: nil)
|
|
120
|
+
Familia.dbclient.hexists(@mffw.dbkey, 'note')
|
|
121
|
+
#=> false
|
|
122
|
+
|
|
123
|
+
## multi_field_fast_write also clears the in-memory value
|
|
124
|
+
@mffw.note
|
|
125
|
+
#=> nil
|
|
126
|
+
|
|
127
|
+
## A Proc-identifier object with all-nil fields does not create a ghost instances entry
|
|
128
|
+
# Nothing is persisted (hmset no-ops), so no hash key is created; the identifier
|
|
129
|
+
# must NOT be registered in the instances timeline, or it would dangle -- listed
|
|
130
|
+
# in instances.to_a while exists? reports false and any load follows a dead ref.
|
|
131
|
+
@ghost = ProcIdAbsenceModel.new
|
|
132
|
+
@ghost.probe_id = "proc-#{Familia.now.to_i}-#{@id_counter += 1}"
|
|
133
|
+
@ghost.save
|
|
134
|
+
[@ghost.exists?, ProcIdAbsenceModel.instances.to_a.include?(@ghost.identifier)]
|
|
135
|
+
#=> [false, false]
|
|
136
|
+
|
|
137
|
+
## Once a Proc-identifier object has a real field value, it persists and registers normally
|
|
138
|
+
@ghost.name = 'now real'
|
|
139
|
+
@ghost.save
|
|
140
|
+
[@ghost.exists?, ProcIdAbsenceModel.instances.to_a.include?(@ghost.identifier)]
|
|
141
|
+
#=> [true, true]
|
|
142
|
+
|
|
143
|
+
## Legacy data: a field stored as the JSON literal "null" is cleaned up on save
|
|
144
|
+
@legacy = NilAbsenceModel.new(id: next_id, name: 'legacy')
|
|
145
|
+
@legacy.save
|
|
146
|
+
# Simulate a hash written by the pre-fix serializer, which stored nil as "null"
|
|
147
|
+
Familia.dbclient.hset(@legacy.dbkey, 'claimed_at', 'null')
|
|
148
|
+
@legacy.refresh! # deserialize_value turns "null" back into nil
|
|
149
|
+
@legacy.save # the now-nil field is actively removed
|
|
150
|
+
Familia.dbclient.hexists(@legacy.dbkey, 'claimed_at')
|
|
151
|
+
#=> false
|
|
152
|
+
|
|
153
|
+
# Final cleanup
|
|
154
|
+
begin
|
|
155
|
+
leftover = Familia.dbclient.keys('nilabsencemodel:*')
|
|
156
|
+
leftover.concat(Familia.dbclient.keys('procidabsencemodel:*'))
|
|
157
|
+
Familia.dbclient.del(*leftover) if leftover.any?
|
|
158
|
+
rescue StandardError
|
|
159
|
+
# ignore cleanup errors
|
|
160
|
+
end
|
|
@@ -66,9 +66,12 @@ Familia.dbclient.set('debug:ending_save_if_not_exists_tests', Familia.now.to_s)
|
|
|
66
66
|
#=> "John Doe"
|
|
67
67
|
|
|
68
68
|
## multi_field_update can update multiple fields atomically, to_h
|
|
69
|
+
# email was never set before the initial save, so under nil-omitting storage it
|
|
70
|
+
# was not persisted; this HSET therefore CREATES the field (returns 1) whereas
|
|
71
|
+
# name already exists (returns 0).
|
|
69
72
|
@result = @customer.multi_field_update(name: 'Jane Windows', email: 'jane@example.com')
|
|
70
73
|
@result.to_h
|
|
71
|
-
#=> {:success=>true, :results=>[0,
|
|
74
|
+
#=> {:success=>true, :results=>[0, 1, false, true]}
|
|
72
75
|
|
|
73
76
|
## multi_field_update returns successful result, successful?
|
|
74
77
|
@result.successful?
|
|
@@ -76,11 +79,11 @@ Familia.dbclient.set('debug:ending_save_if_not_exists_tests', Familia.now.to_s)
|
|
|
76
79
|
|
|
77
80
|
## multi_field_update returns successful result, tuple
|
|
78
81
|
@result.tuple
|
|
79
|
-
#=> [true, [0,
|
|
82
|
+
#=> [true, [0, 1, false, true]]
|
|
80
83
|
|
|
81
84
|
## multi_field_update returns successful result, to_a
|
|
82
85
|
@result.to_a
|
|
83
|
-
#=> [true, [0,
|
|
86
|
+
#=> [true, [0, 1, false, true]]
|
|
84
87
|
|
|
85
88
|
## multi_field_update updates object fields in memory, confirm fields changed
|
|
86
89
|
[@customer.name, @customer.email]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: familia
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.11.
|
|
4
|
+
version: 2.11.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
@@ -123,16 +123,22 @@ dependencies:
|
|
|
123
123
|
name: stringio
|
|
124
124
|
requirement: !ruby/object:Gem::Requirement
|
|
125
125
|
requirements:
|
|
126
|
-
- - "
|
|
126
|
+
- - ">="
|
|
127
127
|
- !ruby/object:Gem::Version
|
|
128
128
|
version: 3.1.1
|
|
129
|
+
- - "<"
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: 3.3.0
|
|
129
132
|
type: :runtime
|
|
130
133
|
prerelease: false
|
|
131
134
|
version_requirements: !ruby/object:Gem::Requirement
|
|
132
135
|
requirements:
|
|
133
|
-
- - "
|
|
136
|
+
- - ">="
|
|
134
137
|
- !ruby/object:Gem::Version
|
|
135
138
|
version: 3.1.1
|
|
139
|
+
- - "<"
|
|
140
|
+
- !ruby/object:Gem::Version
|
|
141
|
+
version: 3.3.0
|
|
136
142
|
- !ruby/object:Gem::Dependency
|
|
137
143
|
name: uri-valkey
|
|
138
144
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -548,6 +554,7 @@ files:
|
|
|
548
554
|
- try/integration/models/customer_try.rb
|
|
549
555
|
- try/integration/models/datatype_base_try.rb
|
|
550
556
|
- try/integration/models/familia_object_try.rb
|
|
557
|
+
- try/integration/nil_field_absence_try.rb
|
|
551
558
|
- try/integration/persistence_operations_try.rb
|
|
552
559
|
- try/integration/relationships_persistence_round_trip_try.rb
|
|
553
560
|
- try/integration/save_methods_consistency_try.rb
|