familia 2.3.0 → 2.3.1
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 +37 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +17 -8
- data/lib/familia/data_type/serialization.rb +1 -1
- data/lib/familia/data_type/types/hashkey.rb +3 -0
- data/lib/familia/data_type/types/json_stringkey.rb +2 -0
- data/lib/familia/data_type/types/listkey.rb +3 -0
- data/lib/familia/data_type/types/sorted_set.rb +3 -0
- data/lib/familia/data_type/types/unsorted_set.rb +3 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +5 -0
- data/lib/familia/horreum/dirty_tracking.rb +21 -15
- data/lib/familia/horreum/management.rb +6 -0
- data/lib/familia/horreum/persistence.rb +42 -8
- data/lib/familia/horreum.rb +1 -0
- data/lib/familia/version.rb +1 -1
- data/try/features/dirty_tracking_try.rb +103 -12
- data/try/features/relationships/relationships_performance_try.rb +4 -1
- metadata +1 -3
- data/pr_agent.toml +0 -36
- data/pr_compliance_checklist.yaml +0 -45
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87748023c0bec120fe0b4936eac184ebe7f72651639b7ff87238782987654cc7
|
|
4
|
+
data.tar.gz: 5a3cbaf9000cf1e12cdcff9f2d35eecca57a010725e501de37485bda7cf214af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 21847a2154326c84d70557ee62ecfc4ea960f74ae6358e41b6c1d6fb7012d58034256a35dbc9aae26778a179cc3524e54012933a84191d2dc570f9d73c400cb8
|
|
7
|
+
data.tar.gz: da077f52d0ecbeba97779470f1e6d3a51934e7e7e3e03b05005e1187794f3ef23c9386aa15fe3747113c31982bf6e56add41948bd78a0d6d2867abaef883a31b
|
data/CHANGELOG.rst
CHANGED
|
@@ -7,6 +7,43 @@ 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.1:
|
|
11
|
+
|
|
12
|
+
2.3.1 — 2026-03-06
|
|
13
|
+
==================
|
|
14
|
+
|
|
15
|
+
Fixed
|
|
16
|
+
-----
|
|
17
|
+
|
|
18
|
+
- Objects loaded from Redis via ``load``, ``find``, ``find_by_id``, ``find_by_dbkey``,
|
|
19
|
+
and ``load_multi`` no longer appear dirty. The ``instantiate_from_hash`` factory
|
|
20
|
+
now calls ``clear_dirty!`` after field assignment, matching the behavior of
|
|
21
|
+
``initialize`` and ``refresh!``. Previously, every loaded object had all fields
|
|
22
|
+
marked dirty, causing false ``warn_if_dirty!`` warnings on subsequent collection
|
|
23
|
+
writes. Fixes `#225 <https://github.com/delano/familia/issues/225>`_.
|
|
24
|
+
|
|
25
|
+
- Added ``warn_if_dirty!`` to 14 secondary collection mutation methods that were
|
|
26
|
+
missing the write-order check: ``remove_element``, ``pop``, ``move`` (UnsortedSet);
|
|
27
|
+
``remove_element``, ``remrangebyrank``, ``remrangebyscore`` (SortedSet); ``pop``,
|
|
28
|
+
``shift``, ``remove_element`` (ListKey); ``hsetnx``, ``remove_field``,
|
|
29
|
+
``update``/``merge!`` (HashKey); ``value=``, ``setnx`` (JsonStringKey). Counter and
|
|
30
|
+
increment methods are intentionally excluded as they operate independently of the
|
|
31
|
+
parent's scalar lifecycle.
|
|
32
|
+
|
|
33
|
+
- ``batch_update`` and ``batch_fast_write`` now update in-memory field values only
|
|
34
|
+
after the MULTI/EXEC transaction succeeds. Previously, setters ran inside the
|
|
35
|
+
transaction block, so a failed transaction could leave the object's in-memory
|
|
36
|
+
state diverged from Redis.
|
|
37
|
+
|
|
38
|
+
AI Assistance
|
|
39
|
+
-------------
|
|
40
|
+
|
|
41
|
+
- Claude identified the one-line fix in ``instantiate_from_hash``, audited all
|
|
42
|
+
collection mutation paths for missing ``warn_if_dirty!`` calls, and triaged
|
|
43
|
+
the 29 candidates into tiers based on write-order risk. Also caught the
|
|
44
|
+
transaction-safety issue in ``batch_update``/``batch_fast_write`` during the
|
|
45
|
+
broader audit.
|
|
46
|
+
|
|
10
47
|
.. _changelog-2.3.0:
|
|
11
48
|
|
|
12
49
|
2.3.0 — 2026-02-26
|
data/Gemfile
CHANGED
|
@@ -5,7 +5,7 @@ source 'https://rubygems.org'
|
|
|
5
5
|
gemspec
|
|
6
6
|
|
|
7
7
|
group :test do
|
|
8
|
-
gem 'concurrent-ruby', '~> 1.3.
|
|
8
|
+
gem 'concurrent-ruby', '~> 1.3.6', require: false
|
|
9
9
|
gem 'ruby-prof'
|
|
10
10
|
gem 'stackprof'
|
|
11
11
|
gem 'timecop', require: false
|
|
@@ -15,12 +15,12 @@ end
|
|
|
15
15
|
group :development, :test do
|
|
16
16
|
gem 'benchmark', '~> 0.4', require: false
|
|
17
17
|
gem 'debug', require: false
|
|
18
|
+
gem 'irb', '~> 1.15.2', require: false
|
|
18
19
|
gem 'json_schemer', '~> 2.0', require: false
|
|
19
20
|
gem 'rake', '~> 13.0', require: false
|
|
20
|
-
gem 'irb', '~> 1.15.2', require: false
|
|
21
21
|
gem 'redcarpet', require: false
|
|
22
22
|
gem 'reek', require: false
|
|
23
|
-
gem 'rubocop', '~> 1.
|
|
23
|
+
gem 'rubocop', '~> 1.85.1', require: false
|
|
24
24
|
gem 'rubocop-performance', require: false
|
|
25
25
|
gem 'rubocop-thread_safety', require: false
|
|
26
26
|
gem 'ruby-lsp', require: false
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
familia (2.3.
|
|
4
|
+
familia (2.3.1)
|
|
5
5
|
concurrent-ruby (~> 1.3)
|
|
6
6
|
connection_pool (>= 2.4, < 4.0)
|
|
7
7
|
csv (~> 3.3)
|
|
@@ -14,6 +14,8 @@ PATH
|
|
|
14
14
|
GEM
|
|
15
15
|
remote: https://rubygems.org/
|
|
16
16
|
specs:
|
|
17
|
+
addressable (2.8.9)
|
|
18
|
+
public_suffix (>= 2.0.2, < 8.0)
|
|
17
19
|
ast (2.4.3)
|
|
18
20
|
base64 (0.3.0)
|
|
19
21
|
benchmark (0.5.0)
|
|
@@ -65,6 +67,9 @@ GEM
|
|
|
65
67
|
rdoc (>= 4.0.0)
|
|
66
68
|
reline (>= 0.4.2)
|
|
67
69
|
json (2.15.1)
|
|
70
|
+
json-schema (6.2.0)
|
|
71
|
+
addressable (~> 2.8)
|
|
72
|
+
bigdecimal (>= 3.1, < 5)
|
|
68
73
|
json_schemer (2.5.0)
|
|
69
74
|
bigdecimal
|
|
70
75
|
hana (~> 1.3)
|
|
@@ -73,6 +78,8 @@ GEM
|
|
|
73
78
|
language_server-protocol (3.17.0.5)
|
|
74
79
|
lint_roller (1.1.0)
|
|
75
80
|
logger (1.7.0)
|
|
81
|
+
mcp (0.8.0)
|
|
82
|
+
json-schema (>= 4.1)
|
|
76
83
|
minitest (5.26.0)
|
|
77
84
|
oj (3.16.13)
|
|
78
85
|
bigdecimal (>= 3.0)
|
|
@@ -87,10 +94,11 @@ GEM
|
|
|
87
94
|
pp (0.6.3)
|
|
88
95
|
prettyprint
|
|
89
96
|
prettyprint (0.2.0)
|
|
90
|
-
prism (1.
|
|
97
|
+
prism (1.9.0)
|
|
91
98
|
psych (5.2.6)
|
|
92
99
|
date
|
|
93
100
|
stringio
|
|
101
|
+
public_suffix (7.0.5)
|
|
94
102
|
racc (1.8.1)
|
|
95
103
|
rainbow (3.1.1)
|
|
96
104
|
rake (13.3.1)
|
|
@@ -130,20 +138,21 @@ GEM
|
|
|
130
138
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
131
139
|
rspec-support (~> 3.13.0)
|
|
132
140
|
rspec-support (3.13.6)
|
|
133
|
-
rubocop (1.
|
|
141
|
+
rubocop (1.85.1)
|
|
134
142
|
json (~> 2.3)
|
|
135
143
|
language_server-protocol (~> 3.17.0.2)
|
|
136
144
|
lint_roller (~> 1.1.0)
|
|
145
|
+
mcp (~> 0.6)
|
|
137
146
|
parallel (~> 1.10)
|
|
138
147
|
parser (>= 3.3.0.2)
|
|
139
148
|
rainbow (>= 2.2.2, < 4.0)
|
|
140
149
|
regexp_parser (>= 2.9.3, < 3.0)
|
|
141
|
-
rubocop-ast (>= 1.
|
|
150
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
142
151
|
ruby-progressbar (~> 1.7)
|
|
143
152
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
144
|
-
rubocop-ast (1.
|
|
153
|
+
rubocop-ast (1.49.0)
|
|
145
154
|
parser (>= 3.3.7.2)
|
|
146
|
-
prism (~> 1.
|
|
155
|
+
prism (~> 1.7)
|
|
147
156
|
rubocop-performance (1.25.0)
|
|
148
157
|
lint_roller (~> 1.1)
|
|
149
158
|
rubocop (>= 1.75.0, < 2.0)
|
|
@@ -189,7 +198,7 @@ PLATFORMS
|
|
|
189
198
|
|
|
190
199
|
DEPENDENCIES
|
|
191
200
|
benchmark (~> 0.4)
|
|
192
|
-
concurrent-ruby (~> 1.3.
|
|
201
|
+
concurrent-ruby (~> 1.3.6)
|
|
193
202
|
debug
|
|
194
203
|
familia!
|
|
195
204
|
irb (~> 1.15.2)
|
|
@@ -198,7 +207,7 @@ DEPENDENCIES
|
|
|
198
207
|
rbnacl (~> 7.1, >= 7.1.1)
|
|
199
208
|
redcarpet
|
|
200
209
|
reek
|
|
201
|
-
rubocop (~> 1.
|
|
210
|
+
rubocop (~> 1.85.1)
|
|
202
211
|
rubocop-performance
|
|
203
212
|
rubocop-thread_safety
|
|
204
213
|
ruby-lsp
|
|
@@ -166,7 +166,7 @@ module Familia
|
|
|
166
166
|
begin
|
|
167
167
|
Familia::JsonSerializer.parse(val)
|
|
168
168
|
rescue Familia::SerializerError
|
|
169
|
-
Familia.
|
|
169
|
+
Familia.warn "[deserialize] Raw fallback in #{dbkey} (#{val.class}, #{val.respond_to?(:bytesize) ? val.bytesize : '?'} bytes)"
|
|
170
170
|
val
|
|
171
171
|
end
|
|
172
172
|
end
|
|
@@ -77,6 +77,7 @@ module Familia
|
|
|
77
77
|
# @param val [Object] The value to set
|
|
78
78
|
# @return [Integer] 1 if field is a new field and value was set, 0 if field already exists
|
|
79
79
|
def hsetnx(field, val)
|
|
80
|
+
warn_if_dirty!
|
|
80
81
|
ret = dbclient.hsetnx dbkey, field.to_s, serialize_value(val)
|
|
81
82
|
update_expiration if ret == 1
|
|
82
83
|
ret
|
|
@@ -100,6 +101,7 @@ module Familia
|
|
|
100
101
|
# @param field [String] The field to remove
|
|
101
102
|
# @return [Integer] The number of fields that were removed (0 or 1)
|
|
102
103
|
def remove_field(field)
|
|
104
|
+
warn_if_dirty!
|
|
103
105
|
ret = dbclient.hdel dbkey, field.to_s
|
|
104
106
|
update_expiration
|
|
105
107
|
ret
|
|
@@ -122,6 +124,7 @@ module Familia
|
|
|
122
124
|
alias decrby decrement
|
|
123
125
|
|
|
124
126
|
def update(hsh = {})
|
|
127
|
+
warn_if_dirty!
|
|
125
128
|
raise ArgumentError, 'Argument to bulk_set must be a hash' unless hsh.is_a?(Hash)
|
|
126
129
|
|
|
127
130
|
data = hsh.inject([]) { |ret, pair| ret << [pair[0], serialize_value(pair[1])] }.flatten
|
|
@@ -80,6 +80,7 @@ module Familia
|
|
|
80
80
|
# @return [String] "OK" on success
|
|
81
81
|
#
|
|
82
82
|
def value=(val)
|
|
83
|
+
warn_if_dirty!
|
|
83
84
|
ret = dbclient.set(dbkey, serialize_value(val))
|
|
84
85
|
update_expiration
|
|
85
86
|
ret
|
|
@@ -93,6 +94,7 @@ module Familia
|
|
|
93
94
|
# @return [Boolean] true if the key was set, false if it already existed
|
|
94
95
|
#
|
|
95
96
|
def setnx(val)
|
|
97
|
+
warn_if_dirty!
|
|
96
98
|
ret = dbclient.setnx(dbkey, serialize_value(val))
|
|
97
99
|
update_expiration if ret
|
|
98
100
|
ret
|
|
@@ -50,12 +50,14 @@ module Familia
|
|
|
50
50
|
alias prepend unshift
|
|
51
51
|
|
|
52
52
|
def pop
|
|
53
|
+
warn_if_dirty!
|
|
53
54
|
ret = deserialize_value dbclient.rpop(dbkey)
|
|
54
55
|
update_expiration
|
|
55
56
|
ret
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
def shift
|
|
60
|
+
warn_if_dirty!
|
|
59
61
|
ret = deserialize_value dbclient.lpop(dbkey)
|
|
60
62
|
update_expiration
|
|
61
63
|
ret
|
|
@@ -85,6 +87,7 @@ module Familia
|
|
|
85
87
|
# @param count [Integer] Number of elements to remove (0 means all)
|
|
86
88
|
# @return [Integer] The number of removed elements
|
|
87
89
|
def remove_element(value, count = 0)
|
|
90
|
+
warn_if_dirty!
|
|
88
91
|
ret = dbclient.lrem dbkey, count, serialize_value(value)
|
|
89
92
|
update_expiration
|
|
90
93
|
ret
|
|
@@ -266,12 +266,14 @@ module Familia
|
|
|
266
266
|
end
|
|
267
267
|
|
|
268
268
|
def remrangebyrank(srank, erank)
|
|
269
|
+
warn_if_dirty!
|
|
269
270
|
ret = dbclient.zremrangebyrank dbkey, srank, erank
|
|
270
271
|
update_expiration
|
|
271
272
|
ret
|
|
272
273
|
end
|
|
273
274
|
|
|
274
275
|
def remrangebyscore(sscore, escore)
|
|
276
|
+
warn_if_dirty!
|
|
275
277
|
ret = dbclient.zremrangebyscore dbkey, sscore, escore
|
|
276
278
|
update_expiration
|
|
277
279
|
ret
|
|
@@ -295,6 +297,7 @@ module Familia
|
|
|
295
297
|
# @param value The value to remove from the sorted set
|
|
296
298
|
# @return [Integer] The number of members that were removed (0 or 1)
|
|
297
299
|
def remove_element(value)
|
|
300
|
+
warn_if_dirty!
|
|
298
301
|
Familia.trace :REMOVE_ELEMENT, nil, "#{value}<#{value.class}>" if Familia.debug?
|
|
299
302
|
ret = dbclient.zrem dbkey, serialize_value(value)
|
|
300
303
|
update_expiration
|
|
@@ -87,6 +87,7 @@ module Familia
|
|
|
87
87
|
# @param value The value to remove from the set
|
|
88
88
|
# @return [Integer] The number of members that were removed (0 or 1)
|
|
89
89
|
def remove_element(value)
|
|
90
|
+
warn_if_dirty!
|
|
90
91
|
ret = dbclient.srem dbkey, serialize_value(value)
|
|
91
92
|
update_expiration
|
|
92
93
|
ret
|
|
@@ -98,12 +99,14 @@ module Familia
|
|
|
98
99
|
end
|
|
99
100
|
|
|
100
101
|
def pop
|
|
102
|
+
warn_if_dirty!
|
|
101
103
|
ret = deserialize_value(dbclient.spop(dbkey))
|
|
102
104
|
update_expiration
|
|
103
105
|
ret
|
|
104
106
|
end
|
|
105
107
|
|
|
106
108
|
def move(dstkey, val)
|
|
109
|
+
warn_if_dirty!
|
|
107
110
|
ret = dbclient.smove dbkey, dstkey, serialize_value(val)
|
|
108
111
|
update_expiration
|
|
109
112
|
ret
|
|
@@ -24,6 +24,8 @@ module Familia
|
|
|
24
24
|
|
|
25
25
|
handle_method_conflict(klass, :"#{method_name}=") do
|
|
26
26
|
klass.define_method :"#{method_name}=" do |value|
|
|
27
|
+
old_value = instance_variable_get(:"@#{field_name}")
|
|
28
|
+
|
|
27
29
|
if value.nil?
|
|
28
30
|
instance_variable_set(:"@#{field_name}", nil)
|
|
29
31
|
elsif value.is_a?(::String) && value.empty?
|
|
@@ -44,6 +46,9 @@ module Familia
|
|
|
44
46
|
concealed = ConcealedString.new(encrypted, self, field_type)
|
|
45
47
|
instance_variable_set(:"@#{field_name}", concealed)
|
|
46
48
|
end
|
|
49
|
+
|
|
50
|
+
# Track the change for dirty-tracking (only for Horreum instances)
|
|
51
|
+
mark_dirty!(field_name, old_value) if respond_to?(:mark_dirty!)
|
|
47
52
|
end
|
|
48
53
|
end
|
|
49
54
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
+
require 'concurrent/map'
|
|
6
|
+
|
|
5
7
|
module Familia
|
|
6
8
|
class Horreum
|
|
7
9
|
# DirtyTracking - Tracks in-memory field changes since last save/refresh.
|
|
@@ -15,6 +17,13 @@ module Familia
|
|
|
15
17
|
# Fields are marked dirty automatically by the setter defined in FieldType.
|
|
16
18
|
# Dirty state is cleared after save, commit_fields, and refresh operations.
|
|
17
19
|
#
|
|
20
|
+
# Uses Concurrent::Map for thread-safe access to the dirty fields tracker
|
|
21
|
+
# without requiring explicit mutex locks. The map is eagerly initialized
|
|
22
|
+
# in Horreum#initialize and the allocate-based load paths so that no
|
|
23
|
+
# lazy ||= race exists under normal usage. The ||= fallbacks in each
|
|
24
|
+
# method are a safety net for subclasses that override initialize
|
|
25
|
+
# without calling super (a documented anti-pattern).
|
|
26
|
+
#
|
|
18
27
|
# @example
|
|
19
28
|
# user = User.new(name: "Alice")
|
|
20
29
|
# user.dirty? # => false (just initialized)
|
|
@@ -37,15 +46,10 @@ module Familia
|
|
|
37
46
|
# @return [void]
|
|
38
47
|
#
|
|
39
48
|
def mark_dirty!(field_name, old_value)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
# Subsequent changes keep the original baseline so changed_fields
|
|
45
|
-
# shows [original, current] rather than [previous, current].
|
|
46
|
-
unless @dirty_fields.key?(field_sym)
|
|
47
|
-
@dirty_fields[field_sym] = old_value
|
|
48
|
-
end
|
|
49
|
+
# Safety net for subclasses that override initialize without calling super
|
|
50
|
+
@dirty_fields ||= Concurrent::Map.new
|
|
51
|
+
# Atomic: only stores old_value if field_sym is not already tracked.
|
|
52
|
+
@dirty_fields.put_if_absent(field_name.to_sym, old_value)
|
|
49
53
|
end
|
|
50
54
|
|
|
51
55
|
# Whether any fields (or a specific field) have unsaved changes.
|
|
@@ -54,8 +58,7 @@ module Familia
|
|
|
54
58
|
# @return [Boolean]
|
|
55
59
|
#
|
|
56
60
|
def dirty?(field = nil)
|
|
57
|
-
@dirty_fields ||=
|
|
58
|
-
|
|
61
|
+
@dirty_fields ||= Concurrent::Map.new
|
|
59
62
|
if field
|
|
60
63
|
@dirty_fields.key?(field.to_sym)
|
|
61
64
|
else
|
|
@@ -68,7 +71,7 @@ module Familia
|
|
|
68
71
|
# @return [Array<Symbol>] field names with unsaved changes
|
|
69
72
|
#
|
|
70
73
|
def dirty_fields
|
|
71
|
-
@dirty_fields ||=
|
|
74
|
+
@dirty_fields ||= Concurrent::Map.new
|
|
72
75
|
@dirty_fields.keys
|
|
73
76
|
end
|
|
74
77
|
|
|
@@ -80,11 +83,13 @@ module Familia
|
|
|
80
83
|
# @return [Hash{Symbol => Array(Object, Object)}]
|
|
81
84
|
#
|
|
82
85
|
def changed_fields
|
|
83
|
-
@dirty_fields ||=
|
|
84
|
-
|
|
86
|
+
@dirty_fields ||= Concurrent::Map.new
|
|
87
|
+
result = {}
|
|
88
|
+
@dirty_fields.each_pair do |field_name, old_value|
|
|
85
89
|
current_value = instance_variable_get(:"@#{field_name}")
|
|
86
90
|
result[field_name] = [old_value, current_value]
|
|
87
91
|
end
|
|
92
|
+
result
|
|
88
93
|
end
|
|
89
94
|
|
|
90
95
|
# Clears dirty tracking state for all or specific fields.
|
|
@@ -98,8 +103,9 @@ module Familia
|
|
|
98
103
|
# @return [void]
|
|
99
104
|
#
|
|
100
105
|
def clear_dirty!(*field_names)
|
|
106
|
+
@dirty_fields ||= Concurrent::Map.new
|
|
101
107
|
if field_names.empty?
|
|
102
|
-
@dirty_fields
|
|
108
|
+
@dirty_fields.clear
|
|
103
109
|
else
|
|
104
110
|
field_names.each { |f| @dirty_fields.delete(f.to_sym) }
|
|
105
111
|
end
|
|
@@ -748,8 +748,13 @@ module Familia
|
|
|
748
748
|
# @api private
|
|
749
749
|
def instantiate_from_hash(obj_hash)
|
|
750
750
|
instance = allocate
|
|
751
|
+
instance.instance_variable_set(:@dirty_fields, Concurrent::Map.new)
|
|
751
752
|
instance.send(:initialize_relatives)
|
|
752
753
|
instance.send(:initialize_with_keyword_args_deserialize_value, **obj_hash)
|
|
754
|
+
# Object was just loaded from Redis, so it matches DB state exactly.
|
|
755
|
+
# Clear dirty flags set during field assignment above, mirroring what
|
|
756
|
+
# initialize (horreum.rb:246) and refresh! (persistence.rb:608) do.
|
|
757
|
+
instance.send(:clear_dirty!)
|
|
753
758
|
instance
|
|
754
759
|
end
|
|
755
760
|
|
|
@@ -794,6 +799,7 @@ module Familia
|
|
|
794
799
|
|
|
795
800
|
# Use a temporary instance for deserialization (needs serialize_value/deserialize_value)
|
|
796
801
|
temp = allocate
|
|
802
|
+
temp.instance_variable_set(:@dirty_fields, Concurrent::Map.new)
|
|
797
803
|
temp.send(:initialize_relatives)
|
|
798
804
|
|
|
799
805
|
raw_hash.each_with_object({}) do |(field, raw_val), result|
|
|
@@ -343,15 +343,14 @@ module Familia
|
|
|
343
343
|
update_expiration = kwargs.delete(:update_expiration) { true }
|
|
344
344
|
fields = kwargs
|
|
345
345
|
|
|
346
|
+
guard_allowed_fields!(fields.keys)
|
|
346
347
|
Familia.trace :BATCH_UPDATE, nil, fields.keys if Familia.debug?
|
|
347
348
|
|
|
348
349
|
result = transaction do |_conn|
|
|
349
|
-
# 1. Update all fields atomically
|
|
350
|
+
# 1. Update all fields atomically (Redis only, no in-memory mutation)
|
|
350
351
|
fields.each do |field, value|
|
|
351
352
|
prepared_value = serialize_value(value)
|
|
352
353
|
hset field, prepared_value
|
|
353
|
-
# Update instance variable to keep object in sync
|
|
354
|
-
send("#{field}=", value) if respond_to?("#{field}=")
|
|
355
354
|
end
|
|
356
355
|
|
|
357
356
|
# 2. Update expiration in same transaction
|
|
@@ -362,7 +361,14 @@ module Familia
|
|
|
362
361
|
touch_instances!
|
|
363
362
|
end
|
|
364
363
|
|
|
365
|
-
|
|
364
|
+
# Update in-memory state only after transaction succeeds,
|
|
365
|
+
# so a failed transaction never leaves the object diverged.
|
|
366
|
+
if result.is_a?(MultiResult) && result.successful?
|
|
367
|
+
fields.each do |field, value|
|
|
368
|
+
send("#{field}=", value) if respond_to?("#{field}=")
|
|
369
|
+
end
|
|
370
|
+
clear_dirty!(*fields.keys)
|
|
371
|
+
end
|
|
366
372
|
|
|
367
373
|
result
|
|
368
374
|
end
|
|
@@ -394,13 +400,13 @@ module Familia
|
|
|
394
400
|
fields = kwargs
|
|
395
401
|
|
|
396
402
|
raise ArgumentError, 'No fields specified' if fields.empty?
|
|
403
|
+
guard_allowed_fields!(fields.keys)
|
|
397
404
|
|
|
398
405
|
Familia.trace :BATCH_FAST_WRITE, nil, fields.keys if Familia.debug?
|
|
399
406
|
|
|
400
|
-
#
|
|
407
|
+
# Serialize values before the transaction (read-only on instance)
|
|
401
408
|
serialized = {}
|
|
402
409
|
fields.each do |field, value|
|
|
403
|
-
send(:"#{field}=", value) if respond_to?(:"#{field}=")
|
|
404
410
|
serialized[field] = serialize_value(value)
|
|
405
411
|
end
|
|
406
412
|
|
|
@@ -412,7 +418,14 @@ module Familia
|
|
|
412
418
|
touch_instances!
|
|
413
419
|
end
|
|
414
420
|
|
|
415
|
-
|
|
421
|
+
# Update in-memory state only after transaction succeeds,
|
|
422
|
+
# so a failed transaction never leaves the object diverged.
|
|
423
|
+
if result.is_a?(MultiResult) && result.successful?
|
|
424
|
+
fields.each do |field, value|
|
|
425
|
+
send(:"#{field}=", value) if respond_to?(:"#{field}=")
|
|
426
|
+
end
|
|
427
|
+
clear_dirty!(*fields.keys)
|
|
428
|
+
end
|
|
416
429
|
|
|
417
430
|
self
|
|
418
431
|
end
|
|
@@ -477,8 +490,8 @@ module Familia
|
|
|
477
490
|
# # => #<User:0x007f8a1c8b0a28 @name="John", @email="john@example.com", @age=30>
|
|
478
491
|
#
|
|
479
492
|
def apply_fields(**fields)
|
|
493
|
+
guard_allowed_fields!(fields.keys)
|
|
480
494
|
fields.each do |field, value|
|
|
481
|
-
# Apply the field value if the setter method exists
|
|
482
495
|
send("#{field}=", value) if respond_to?("#{field}=")
|
|
483
496
|
end
|
|
484
497
|
self
|
|
@@ -695,6 +708,27 @@ module Familia
|
|
|
695
708
|
|
|
696
709
|
private
|
|
697
710
|
|
|
711
|
+
# Validates that all field names are declared Familia fields.
|
|
712
|
+
#
|
|
713
|
+
# Prevents mass-assignment of arbitrary setter methods (e.g. role=,
|
|
714
|
+
# admin=) that are not declared via the `field` or `transient` DSL.
|
|
715
|
+
# This is a defense-in-depth measure for downstream callers that may
|
|
716
|
+
# inadvertently pass unsanitized input to batch methods.
|
|
717
|
+
#
|
|
718
|
+
# @param names [Array<Symbol, String>] field names to validate
|
|
719
|
+
# @raise [ArgumentError] if any name is not a declared field
|
|
720
|
+
# @return [void]
|
|
721
|
+
#
|
|
722
|
+
def guard_allowed_fields!(names)
|
|
723
|
+
allowed = self.class.field_method_map.keys
|
|
724
|
+
unknown = names.map(&:to_sym) - allowed
|
|
725
|
+
return if unknown.empty?
|
|
726
|
+
|
|
727
|
+
raise ArgumentError,
|
|
728
|
+
"Undeclared fields for #{self.class}: #{unknown.join(', ')}. " \
|
|
729
|
+
"Only fields defined with `field` or `transient` are mass-assignable."
|
|
730
|
+
end
|
|
731
|
+
|
|
698
732
|
# Reset all transient fields to nil
|
|
699
733
|
#
|
|
700
734
|
# This method ensures that transient fields return to their uninitialized
|
data/lib/familia/horreum.rb
CHANGED
|
@@ -189,6 +189,7 @@ module Familia
|
|
|
189
189
|
# `Session.new({sessid: "abc123", custid: "user456"})` # legacy hash (robust)
|
|
190
190
|
#
|
|
191
191
|
def initialize(*args, **kwargs)
|
|
192
|
+
@dirty_fields = Concurrent::Map.new
|
|
192
193
|
start_time = Familia.now_in_μs if Familia.debug?
|
|
193
194
|
Familia.trace :INITIALIZE, nil, "Initializing #{self.class}" if Familia.debug?
|
|
194
195
|
initialize_relatives
|
data/lib/familia/version.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require_relative '../support/helpers/test_helpers'
|
|
6
|
+
require 'base64'
|
|
6
7
|
|
|
7
8
|
class DirtyTrackUser < Familia::Horreum
|
|
8
9
|
identifier_field :email
|
|
@@ -12,6 +13,19 @@ class DirtyTrackUser < Familia::Horreum
|
|
|
12
13
|
field :active
|
|
13
14
|
end
|
|
14
15
|
|
|
16
|
+
# Encrypted field model for dirty tracking tests
|
|
17
|
+
# Encryption keys must be configured before defining the class
|
|
18
|
+
Familia.config.encryption_keys = { v1: Base64.strict_encode64('a' * 32) }
|
|
19
|
+
Familia.config.current_key_version = :v1
|
|
20
|
+
|
|
21
|
+
class DirtyTrackSecureUser < Familia::Horreum
|
|
22
|
+
feature :encrypted_fields
|
|
23
|
+
identifier_field :user_id
|
|
24
|
+
field :user_id
|
|
25
|
+
field :display_name
|
|
26
|
+
encrypted_field :secret_token
|
|
27
|
+
end
|
|
28
|
+
|
|
15
29
|
@user = DirtyTrackUser.new(email: 'alice@example.com', name: 'Alice', age: 30, active: true)
|
|
16
30
|
|
|
17
31
|
## Freshly constructed object is not dirty
|
|
@@ -181,19 +195,13 @@ end
|
|
|
181
195
|
@wp.dirty?
|
|
182
196
|
#=> false
|
|
183
197
|
|
|
184
|
-
#
|
|
198
|
+
# Selective clear_dirty! for partial write paths
|
|
185
199
|
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
# were NOT persisted, causing the object to report as clean when it still
|
|
190
|
-
# has unsaved changes.
|
|
191
|
-
#
|
|
192
|
-
# These tests document the correct behavior. They are expected to FAIL
|
|
193
|
-
# with the current implementation until clear_dirty! is fixed to only
|
|
194
|
-
# clear the fields that were actually written.
|
|
200
|
+
# Partial write paths (fast writers, save_fields, batch_update) only persist
|
|
201
|
+
# a subset of fields. clear_dirty! selectively clears only the fields that
|
|
202
|
+
# were actually written, preserving dirty state for unwritten fields.
|
|
195
203
|
|
|
196
|
-
## Fast writer
|
|
204
|
+
## Fast writer preserves dirty state for unwritten fields
|
|
197
205
|
# When fields A and B are both dirty and only A is fast-written,
|
|
198
206
|
# field B should still be marked dirty because it was not persisted.
|
|
199
207
|
@bug1 = DirtyTrackUser.new(email: 'bug1@example.com', name: 'Original', age: 20)
|
|
@@ -217,7 +225,7 @@ end
|
|
|
217
225
|
@bug1.dirty_fields
|
|
218
226
|
#=> [:age]
|
|
219
227
|
|
|
220
|
-
## save_fields
|
|
228
|
+
## save_fields preserves dirty state for unwritten fields
|
|
221
229
|
# When fields A and B are both dirty and only A is saved via save_fields,
|
|
222
230
|
# field B should still be marked dirty because it was not persisted.
|
|
223
231
|
@bug2 = DirtyTrackUser.new(email: 'bug2@example.com', name: 'Original', age: 20)
|
|
@@ -497,8 +505,91 @@ end
|
|
|
497
505
|
@clean.dirty?
|
|
498
506
|
#=> false
|
|
499
507
|
|
|
508
|
+
# Issue #225: Objects loaded from Redis should not be dirty
|
|
509
|
+
# instantiate_from_hash (used by load, find_by_dbkey) was missing
|
|
510
|
+
# clear_dirty!, so loaded objects appeared dirty even though they matched DB state.
|
|
511
|
+
|
|
512
|
+
## Object loaded via load should not be dirty
|
|
513
|
+
@loaded_user = DirtyTrackUser.new(email: 'load-clean@example.com', name: 'LoadClean', age: 25, active: true)
|
|
514
|
+
@loaded_user.save
|
|
515
|
+
@reloaded = DirtyTrackUser.load('load-clean@example.com')
|
|
516
|
+
@reloaded.dirty?
|
|
517
|
+
#=> false
|
|
518
|
+
|
|
519
|
+
## Loaded object should have empty dirty_fields
|
|
520
|
+
@reloaded.dirty_fields
|
|
521
|
+
#=> []
|
|
522
|
+
|
|
523
|
+
## Loaded object should have empty changed_fields
|
|
524
|
+
@reloaded.changed_fields
|
|
525
|
+
#=> {}
|
|
526
|
+
|
|
527
|
+
## Object loaded via find_by_dbkey should not be dirty
|
|
528
|
+
@found = DirtyTrackUser.find_by_dbkey(DirtyTrackUser.dbkey('load-clean@example.com'))
|
|
529
|
+
@found.dirty?
|
|
530
|
+
#=> false
|
|
531
|
+
|
|
532
|
+
## Modifying a loaded object marks it dirty as expected
|
|
533
|
+
@reloaded.name = 'Modified'
|
|
534
|
+
@reloaded.dirty?
|
|
535
|
+
#=> true
|
|
536
|
+
|
|
537
|
+
## Loaded object dirty_fields reflects the modification
|
|
538
|
+
@reloaded.dirty_fields
|
|
539
|
+
#=> [:name]
|
|
540
|
+
|
|
541
|
+
# Encrypted field dirty tracking tests
|
|
542
|
+
#
|
|
543
|
+
# Encrypted field setters (EncryptedFieldType#define_setter) use
|
|
544
|
+
# instance_variable_set directly without calling mark_dirty!. This means
|
|
545
|
+
# setting an encrypted field does not mark the object as dirty, even though
|
|
546
|
+
# encrypted fields ARE persisted to Redis just like regular fields.
|
|
547
|
+
#
|
|
548
|
+
# These tests document the correct behavior and should FAIL until
|
|
549
|
+
# mark_dirty! is added to the encrypted field setter.
|
|
550
|
+
|
|
551
|
+
## Setting an encrypted field should mark object as dirty
|
|
552
|
+
@enc1 = DirtyTrackSecureUser.new(user_id: 'enc-dirty-1', display_name: 'EncUser1')
|
|
553
|
+
@enc1.save
|
|
554
|
+
@enc1 = DirtyTrackSecureUser.load('enc-dirty-1')
|
|
555
|
+
@enc1.secret_token = 'my-secret-abc'
|
|
556
|
+
@enc1.dirty?
|
|
557
|
+
#=> true
|
|
558
|
+
|
|
559
|
+
## Encrypted field should appear in dirty_fields
|
|
560
|
+
@enc1.dirty_fields
|
|
561
|
+
#=> [:secret_token]
|
|
562
|
+
|
|
563
|
+
## Encrypted field should appear in changed_fields
|
|
564
|
+
@enc1.changed_fields.key?(:secret_token)
|
|
565
|
+
#=> true
|
|
566
|
+
|
|
567
|
+
## Save should clear encrypted field dirty state
|
|
568
|
+
@enc2 = DirtyTrackSecureUser.new(user_id: 'enc-dirty-2', display_name: 'EncUser2')
|
|
569
|
+
@enc2.save
|
|
570
|
+
@enc2 = DirtyTrackSecureUser.load('enc-dirty-2')
|
|
571
|
+
@enc2.secret_token = 'another-secret'
|
|
572
|
+
@enc2.save
|
|
573
|
+
@enc2.dirty?
|
|
574
|
+
#=> false
|
|
575
|
+
|
|
576
|
+
## Mixed dirty tracking: both regular and encrypted fields appear in dirty_fields
|
|
577
|
+
@enc3 = DirtyTrackSecureUser.new(user_id: 'enc-dirty-3', display_name: 'EncUser3')
|
|
578
|
+
@enc3.save
|
|
579
|
+
@enc3 = DirtyTrackSecureUser.load('enc-dirty-3')
|
|
580
|
+
@enc3.display_name = 'UpdatedName'
|
|
581
|
+
@enc3.secret_token = 'secret-xyz'
|
|
582
|
+
@enc3.dirty_fields.sort
|
|
583
|
+
#=> [:display_name, :secret_token]
|
|
584
|
+
|
|
500
585
|
## Teardown
|
|
501
586
|
DirtyTrackUser.instances.members.each do |id|
|
|
502
587
|
obj = DirtyTrackUser.new(id)
|
|
503
588
|
obj.destroy! rescue nil
|
|
504
589
|
end
|
|
590
|
+
DirtyTrackSecureUser.instances.members.each do |id|
|
|
591
|
+
obj = DirtyTrackSecureUser.new(id)
|
|
592
|
+
obj.destroy! rescue nil
|
|
593
|
+
end
|
|
594
|
+
Familia.config.encryption_keys = nil
|
|
595
|
+
Familia.config.current_key_version = nil
|
|
@@ -157,7 +157,10 @@ instances = 100.times.map { |i| PerfDomain.new(domain_id: "mem_test_#{i}") }
|
|
|
157
157
|
after_instances = ObjectSpace.count_objects[:T_OBJECT]
|
|
158
158
|
|
|
159
159
|
# Should not have created excessive objects (relationship metadata is shared)
|
|
160
|
-
|
|
160
|
+
# Threshold accommodates per-instance allocations (Concurrent::Map for dirty
|
|
161
|
+
# tracking, DataType relatives, etc.) which vary across Ruby versions.
|
|
162
|
+
# Ruby 3.5+ allocates more T_OBJECTs internally per instance.
|
|
163
|
+
(after_instances - before_instances) < 800
|
|
161
164
|
#=> true
|
|
162
165
|
|
|
163
166
|
## Class-level relationship metadata should be constant size
|
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.3.
|
|
4
|
+
version: 2.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
@@ -330,8 +330,6 @@ files:
|
|
|
330
330
|
- lib/middleware/database_command_counter.rb
|
|
331
331
|
- lib/middleware/database_logger.rb
|
|
332
332
|
- lib/multi_result.rb
|
|
333
|
-
- pr_agent.toml
|
|
334
|
-
- pr_compliance_checklist.yaml
|
|
335
333
|
- try/audit/audit_instances_try.rb
|
|
336
334
|
- try/audit/audit_report_try.rb
|
|
337
335
|
- try/audit/audit_unique_indexes_try.rb
|
data/pr_agent.toml
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# Qodo Merge Configuration
|
|
2
|
-
# Documentation: https://qodo-merge-docs.qodo.ai/usage-guide/configuration_options/
|
|
3
|
-
|
|
4
|
-
[config]
|
|
5
|
-
# Ensure consistent review language across all PRs
|
|
6
|
-
response_language = "en"
|
|
7
|
-
|
|
8
|
-
[rag_arguments]
|
|
9
|
-
# Enable RAG context enrichment for codebase duplication compliance checks
|
|
10
|
-
enable_rag = true
|
|
11
|
-
# Include related repositories for comprehensive context
|
|
12
|
-
rag_repo_list = ['onetimesecret/onetimesecret', 'delano/tryouts']
|
|
13
|
-
|
|
14
|
-
[compliance]
|
|
15
|
-
# Reference custom compliance checklist for project-specific rules
|
|
16
|
-
custom_compliance_path = "pr_compliance_checklist.yaml"
|
|
17
|
-
|
|
18
|
-
[pr_reviewer]
|
|
19
|
-
# Disable automatic label additions (triggers Claude review workflow noise)
|
|
20
|
-
enable_review_labels_security = false
|
|
21
|
-
enable_review_labels_effort = false
|
|
22
|
-
|
|
23
|
-
[ignore]
|
|
24
|
-
# Reduce noise by excluding generated files and build artifacts
|
|
25
|
-
glob = [
|
|
26
|
-
"*.lock", # Lock files (Gemfile.lock, etc.)
|
|
27
|
-
"*.gem", # Built gem files
|
|
28
|
-
"vendor/**", # Vendored dependencies
|
|
29
|
-
"tmp/**", # Temporary files
|
|
30
|
-
"log/**", # Log files
|
|
31
|
-
"data/**", # Data directories
|
|
32
|
-
"public/**", # Public assets
|
|
33
|
-
".yardoc/**", # YARD documentation cache
|
|
34
|
-
"dump.rdb", # Redis database dumps
|
|
35
|
-
"appendonlydir/**", # Redis append-only file directory
|
|
36
|
-
]
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
# Custom Compliance Checklist for Familia
|
|
2
|
-
# Documentation: https://qodo-merge-docs.qodo.ai/tools/compliance/
|
|
3
|
-
|
|
4
|
-
pr_compliances:
|
|
5
|
-
- title: "ErrorHandling"
|
|
6
|
-
compliance_label: true
|
|
7
|
-
objective: "All external API calls and database operations must have proper error handling"
|
|
8
|
-
success_criteria: "Try-catch blocks around external calls with appropriate logging or error handling mechanisms"
|
|
9
|
-
failure_criteria: "External API calls, database operations, or network requests without error handling"
|
|
10
|
-
|
|
11
|
-
- title: "TestCoverage"
|
|
12
|
-
compliance_label: true
|
|
13
|
-
objective: "New features must include corresponding tests using the Tryouts framework"
|
|
14
|
-
success_criteria: "Test files present in try/ directory for new functionality following *_try.rb or *.try.rb naming convention"
|
|
15
|
-
failure_criteria: "New code without test coverage or tests not following Tryouts framework conventions"
|
|
16
|
-
|
|
17
|
-
- title: "ChangelogFragment"
|
|
18
|
-
compliance_label: true
|
|
19
|
-
objective: "User-facing changes must include a changelog"
|
|
20
|
-
success_criteria: "New fragment file in changelog.d/ directory following the naming convention and RST format, or updates to CHANGELOG.rst in root directory, or explicit justification for omission"
|
|
21
|
-
failure_criteria: "User-facing changes without changelog fragment, CHANGELOG.rst updates, or documentation updates"
|
|
22
|
-
|
|
23
|
-
- title: "DocumentationUpdates"
|
|
24
|
-
compliance_label: true
|
|
25
|
-
objective: "API changes must be reflected in documentation"
|
|
26
|
-
success_criteria: "YARD documentation comments for new public methods, or updates to docs/ for significant changes"
|
|
27
|
-
failure_criteria: "New public APIs or significant behavior changes without documentation updates"
|
|
28
|
-
|
|
29
|
-
- title: "BackwardCompatibility"
|
|
30
|
-
compliance_label: true
|
|
31
|
-
objective: "Changes must maintain backward compatibility or document breaking changes"
|
|
32
|
-
success_criteria: "No breaking changes to public APIs, or breaking changes clearly documented in migration guides"
|
|
33
|
-
failure_criteria: "Breaking changes without migration documentation or deprecation warnings"
|
|
34
|
-
|
|
35
|
-
- title: "ThreadSafety"
|
|
36
|
-
compliance_label: true
|
|
37
|
-
objective: "Code handling shared state must be thread-safe"
|
|
38
|
-
success_criteria: "Proper synchronization for shared mutable state, or clear documentation of thread-safety assumptions"
|
|
39
|
-
failure_criteria: "Shared mutable state accessed without synchronization in concurrent contexts"
|
|
40
|
-
|
|
41
|
-
- title: "DatabaseKeyNaming"
|
|
42
|
-
compliance_label: true
|
|
43
|
-
objective: "Database key generation must follow Familia conventions"
|
|
44
|
-
success_criteria: "Keys use delim separator, avoid reserved keywords (ttl, db, valkey, redis), and handle empty identifiers"
|
|
45
|
-
failure_criteria: "Keys using reserved keywords, empty identifiers, or non-standard separators"
|