familia 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/.pre-commit-config.yaml +0 -2
  3. data/CHANGELOG.rst +236 -0
  4. data/Gemfile.lock +2 -2
  5. data/docs/guides/feature-housekeeping.md +66 -36
  6. data/docs/migrating/v2.9.0.md +125 -0
  7. data/familia.gemspec +1 -1
  8. data/lib/familia/batch_result.rb +158 -0
  9. data/lib/familia/connection/handlers.rb +131 -47
  10. data/lib/familia/connection/operations.rb +5 -4
  11. data/lib/familia/connection/pipelined_core.rb +14 -7
  12. data/lib/familia/connection/transaction_core.rb +9 -0
  13. data/lib/familia/connection.rb +2 -1
  14. data/lib/familia/data_type/collection_base.rb +132 -0
  15. data/lib/familia/data_type/connection.rb +21 -52
  16. data/lib/familia/data_type/scalar_base.rb +33 -0
  17. data/lib/familia/data_type/types/hashkey.rb +284 -0
  18. data/lib/familia/data_type/types/json_stringkey.rb +2 -0
  19. data/lib/familia/data_type/types/listkey.rb +151 -17
  20. data/lib/familia/data_type/types/sorted_set.rb +455 -21
  21. data/lib/familia/data_type/types/stringkey.rb +166 -0
  22. data/lib/familia/data_type/types/unsorted_set.rb +155 -15
  23. data/lib/familia/data_type.rb +2 -0
  24. data/lib/familia/errors.rb +4 -0
  25. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +18 -0
  26. data/lib/familia/features/housekeeping.rb +112 -18
  27. data/lib/familia/field_type.rb +18 -0
  28. data/lib/familia/horreum/connection.rb +15 -10
  29. data/lib/familia/horreum/definition.rb +18 -0
  30. data/lib/familia/horreum/management/audit.rb +37 -39
  31. data/lib/familia/horreum/persistence.rb +23 -19
  32. data/lib/familia/horreum.rb +9 -6
  33. data/lib/familia/multi_result.rb +111 -0
  34. data/lib/familia/version.rb +1 -1
  35. data/lib/familia.rb +2 -1
  36. data/try/edge_cases/fast_writer_transaction_guard_try.rb +130 -0
  37. data/try/edge_cases/iterator_connection_errors_try.rb +97 -0
  38. data/try/edge_cases/pipeline_handler_edge_cases_try.rb +214 -0
  39. data/try/edge_cases/ttl_side_effects_try.rb +1 -1
  40. data/try/features/atomic_write_coverage_try.rb +1 -1
  41. data/try/features/atomic_write_try.rb +11 -9
  42. data/try/features/atomicity_try.rb +2 -2
  43. data/try/features/dirty_tracking_try.rb +21 -21
  44. data/try/features/housekeeping/housekeeping_try.rb +200 -21
  45. data/try/features/instance_registry_try.rb +2 -2
  46. data/try/integration/connection/handler_constraints_try.rb +8 -8
  47. data/try/integration/connection/operation_mode_guards_try.rb +3 -3
  48. data/try/integration/connection/pipeline_fallback_integration_try.rb +4 -4
  49. data/try/integration/connection/pipeline_handler_integration_try.rb +175 -0
  50. data/try/integration/connection/pipeline_horreum_routing_try.rb +147 -0
  51. data/try/integration/connection/pools_try.rb +1 -1
  52. data/try/integration/connection/transaction_fallback_integration_try.rb +4 -4
  53. data/try/integration/connection/transaction_mode_permissive_try.rb +8 -8
  54. data/try/integration/connection/transaction_mode_strict_try.rb +2 -2
  55. data/try/integration/connection/transaction_mode_warn_try.rb +5 -5
  56. data/try/integration/connection/transaction_modes_try.rb +14 -14
  57. data/try/integration/data_types/datatype_pipelines_try.rb +35 -15
  58. data/try/integration/data_types/datatype_transactions_try.rb +30 -28
  59. data/try/integration/database_consistency_try.rb +1 -1
  60. data/try/integration/models/familia_object_try.rb +1 -1
  61. data/try/integration/transaction_safety_core_try.rb +1 -1
  62. data/try/integration/transaction_safety_workflow_try.rb +2 -2
  63. data/try/support/prototypes/atomic_saves_v2_connection_switching.rb +1 -1
  64. data/try/support/prototypes/lib/atomic_saves_v2_connection_switching_helpers.rb +1 -1
  65. data/try/support/prototypes/pooling/lib/connection_pool_stress_test.rb +1 -1
  66. data/try/thread_safety/fiber_pipeline_isolation_try.rb +11 -19
  67. data/try/unit/batch_result_try.rb +348 -0
  68. data/try/unit/data_types/each_record_try.rb +298 -0
  69. data/try/unit/data_types/enumerable_consistency/concurrent_modification_try.rb +176 -0
  70. data/try/unit/data_types/enumerable_consistency/hashkey_consistency_try.rb +224 -0
  71. data/try/unit/data_types/enumerable_consistency/large_scale_consistency_try.rb +292 -0
  72. data/try/unit/data_types/enumerable_consistency/listkey_consistency_try.rb +230 -0
  73. data/try/unit/data_types/enumerable_consistency/sorted_set_consistency_try.rb +241 -0
  74. data/try/unit/data_types/enumerable_consistency/unsorted_set_consistency_try.rb +261 -0
  75. data/try/unit/data_types/enumerable_try.rb +228 -0
  76. data/try/unit/data_types/hashkey_each_try.rb +213 -0
  77. data/try/unit/data_types/hashkey_operations_try.rb +269 -0
  78. data/try/unit/data_types/list_commands_try.rb +314 -0
  79. data/try/unit/data_types/listkey_each_try.rb +222 -0
  80. data/try/unit/data_types/sorted_set_each_try.rb +227 -0
  81. data/try/unit/data_types/sortedset_operations_try.rb +467 -0
  82. data/try/unit/data_types/stringkey_extended_try.rb +239 -0
  83. data/try/unit/data_types/unsorted_set_each_try.rb +185 -0
  84. data/try/unit/data_types/unsortedset_operations_try.rb +174 -0
  85. data/try/unit/fiber_pipeline_handler_try.rb +147 -0
  86. data/try/unit/horreum/base_try.rb +1 -1
  87. data/try/unit/horreum/destroy_related_fields_cleanup_try.rb +2 -2
  88. data/try/unit/horreum/initialization_try.rb +1 -1
  89. data/try/unit/horreum/json_type_preservation_try.rb +3 -3
  90. data/try/unit/horreum/multi_field_update_try.rb +143 -0
  91. data/try/unit/horreum/serialization_try.rb +14 -14
  92. metadata +33 -5
  93. data/changelog.d/20260514_034522_claude_review_familia_issue_217.rst +0 -46
  94. data/lib/multi_result.rb +0 -109
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7b0138946cb9d48258b9f43b8a8a81aa895bfded213543feee354b3d383dd98a
4
- data.tar.gz: bd5b6ee397c2ef19cef85fe7638ead34b5ed40a81c8362f539ed757d0d713ca2
3
+ metadata.gz: 42bbbbcb737ab4222505955e5b1af2ab72ed962ba80a02cf324206b4f2379b94
4
+ data.tar.gz: 331b1d0bb0808618a87d962e1b09af61a31d59b7b8d56b0f49513cb9f3fec63d
5
5
  SHA512:
6
- metadata.gz: 8108ee045d1f4f1bdc722a1b56a6518a32edebd18ae028dfdcd793a62044cc8bea509e06d8399179c11b1eeef971dd980d1bcd18654cd3a375af99b641c514a6
7
- data.tar.gz: 395bcb27503bb30734d0d6e16fa8e95b33fa072dfaca56c2b9b06d33859c4cb915f766923a492cbdaae5407e45db2c76ee074a6c6d7aac245b822332f44d082c
6
+ metadata.gz: 9ff40710ce23c2f3dadbfbf68142800b8f10590c898f30f424819a4f0efea9aa545c52307c487c6fd96bf0c0f95f7504e5614056a30039f75f9db84fe1fcd595
7
+ data.tar.gz: be6c618b3fab3eb5ac8577c8a4bd7fbbbc53ce300da750baf3f64d70807a0aeb2a3a7a7f28da91d7637ebe86d9f2ccc86b677478ed90ab3ae7878d6d8816cd09
@@ -62,8 +62,6 @@ repos:
62
62
  rev: v1.32.2
63
63
  hooks:
64
64
  - id: talisman-commit
65
- env:
66
- TALISMAN_SCAN_MODE: CI
67
65
 
68
66
  # Commit message issue tracking integration
69
67
  - repo: https://github.com/delano/add-msg-issue-prefix-hook
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,242 @@ 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.9.0:
11
+
12
+ 2.9.0 — 2026-05-17
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Batch iteration primitives for DataTypes via ``Enumerable`` integration:
19
+
20
+ - All DataTypes (``SortedSet``, ``HashKey``, ``UnsortedSet``, ``ListKey``) now
21
+ ``include Enumerable``, providing ``each_slice``, ``lazy``, ``map``, ``reduce``,
22
+ ``find``, and other stdlib methods.
23
+
24
+ - **SortedSet#each(since:, until:)**: Cursor-based iteration with optional
25
+ timestamp bounds. Uses ZRANGEBYSCORE when bounds provided (inclusive),
26
+ ZSCAN otherwise. Accepts Time objects or numeric scores.
27
+
28
+ - **HashKey#each(matching:)**: Cursor-based iteration via HSCAN with optional
29
+ glob pattern filter on field names.
30
+
31
+ - **UnsortedSet#each(matching:)**: Cursor-based iteration via SSCAN with optional
32
+ glob pattern filter using Redis SSCAN MATCH on raw values.
33
+
34
+ - **ListKey#each(batch_size:)**: Memory-efficient LRANGE pagination for large lists.
35
+
36
+ - ``DataType#each_record(batch_size:, write_size:, **filters)`` yields loaded
37
+ Horreum records (not raw IDs) via ``load_multi``. Ghost instances (expired keys
38
+ still in ``instances``) are automatically filtered. The ``write_size:`` parameter
39
+ controls pipelining depth (``nil`` for serial execution).
40
+
41
+ - ``Familia::BatchResult`` value type for aggregating batch operation results:
42
+
43
+ - ``BatchResult.collect(enumerable, strict: false) { |record| ... }`` iterates
44
+ any Enumerable, tracking ``scanned``, ``modified`` (truthy returns), ``errors``
45
+ (array of ``{id:, error:}``), and ``duration_ms``.
46
+
47
+ - Per-record exception isolation: errors are captured and iteration continues.
48
+
49
+ - ``strict: true`` re-raises collected errors after iteration completes.
50
+
51
+ Changed
52
+ -------
53
+
54
+ - Renamed batch field-update methods for clarity:
55
+
56
+ - ``batch_update`` is now ``multi_field_update``
57
+ - ``batch_fast_write`` is now ``multi_field_fast_write``
58
+
59
+ Old names removed without deprecation shim (breaking change).
60
+
61
+ - Moved ``MultiResult`` into Familia namespace as ``Familia::MultiResult``.
62
+ Old top-level constant removed without backwards-compat alias (breaking change).
63
+
64
+ AI Assistance
65
+ -------------
66
+
67
+ - Implementation and test coverage developed with parallel Claude Code agents:
68
+ one for production code (DataType iteration, BatchResult, renames), one for
69
+ Tryouts test suite (228 new tests across 8 files). PR #264.
70
+
71
+ .. _changelog-2.8.0:
72
+
73
+ 2.8.0 — 2026-05-15
74
+ ==================
75
+
76
+ Added
77
+ -----
78
+
79
+ - Expanded Redis 7 command coverage across all DataType classes:
80
+
81
+ - **StringKey**: ``incrbyfloat``, ``getex``, ``getdel``, ``setex``, ``psetex``,
82
+ ``bitcount``, ``bitpos``, ``bitfield``, plus class methods ``mget``, ``mset``,
83
+ ``msetnx``, ``bitop`` for multi-key and bitwise operations.
84
+
85
+ - **List**: ``trim``/``ltrim``, ``set``/``lset``, ``insert``/``linsert``,
86
+ ``move``/``lmove``, ``pushx``/``rpushx``, ``unshiftx``/``lpushx``.
87
+ Updated ``pop`` and ``shift`` to support optional count parameter for batch operations.
88
+
89
+ - **UnsortedSet**: ``intersection``/``inter``, ``union``, ``difference``/``diff``,
90
+ ``member_any?``/``members?``, ``scan``, ``intercard``/``intersection_cardinality``,
91
+ ``interstore``/``intersection_store``, ``unionstore``/``union_store``,
92
+ ``diffstore``/``difference_store``.
93
+
94
+ - **SortedSet**: ``popmin``, ``popmax``, ``score_count``/``zcount``, ``mscore``,
95
+ ``union``, ``inter``, ``rangebylex``, ``revrangebylex``, ``remrangebylex``,
96
+ ``lexcount``, ``randmember``, ``scan``, ``unionstore``, ``interstore``,
97
+ ``diff``, ``diffstore``.
98
+
99
+ - **HashKey**: ``scan``/``hscan``, ``incrbyfloat``/``incrfloat``,
100
+ ``strlen``/``hstrlen``, ``randfield``/``hrandfield``, plus field-level TTL
101
+ commands (Redis 7.4+): ``expire_fields``, ``pexpire_fields``, ``expireat_fields``,
102
+ ``pexpireat_fields``, ``ttl_fields``, ``pttl_fields``, ``persist_fields``,
103
+ ``expiretime_fields``, ``pexpiretime_fields``.
104
+
105
+ - Added 158 new tests across 5 test files covering all new methods.
106
+
107
+ - Instance-scoped ``audit_multi_indexes`` is now fully implemented.
108
+ Discovers per-scope bucket keys via SCAN, partitions them by scope
109
+ instance, and reports stale members, orphaned buckets, and missing
110
+ entries in the same shape as the class-level audit. Orphan entries
111
+ carry a ``:reason`` (``:scope_missing`` or ``:field_value_unheld``)
112
+ and a ``:scope_id``. Missing entries are detected via the indexed
113
+ class's ``participates_in`` relationship to the scope class; when
114
+ absent, the result carries ``missing_status: :not_audited``.
115
+ Resolves the ``:not_implemented`` follow-up from #217.
116
+
117
+ - ``repair_multi_indexes!`` class method that invokes the existing
118
+ ``rebuild_<index_name>`` methods for both class-level (one call on
119
+ the indexed class) and instance-scoped (one call per scope
120
+ instance) multi-indexes. Indexes whose audit status is ``:ok`` are
121
+ skipped; rebuild methods that don't exist or scope classes
122
+ without an ``instances`` collection are recorded in ``:skipped``
123
+ with a reason.
124
+
125
+ - ``housekeeping`` feature gains a class-level bulk runner,
126
+ ``Klass.run_chores!(chore_name:, limit:, batch_size:)``. It iterates
127
+ the class's ``instances`` collection in pipelined batches via
128
+ ``load_multi``, runs all registered chores (or one named chore)
129
+ against each record, and returns a stats hash:
130
+ ``{ model:, scanned:, chores: { name => { modified:, errors: } } }``.
131
+ Truthy chore returns increment ``modified``; raised exceptions are
132
+ isolated per-record, logged via ``Familia.warn``, and counted as
133
+ ``errors`` so a single failure doesn't halt the run. Lifted from the
134
+ shape proven out in OneTime Secret's ``HousekeepingJob``.
135
+
136
+ - Trace events for connection-mode conflicts. ``Familia.trace`` now
137
+ emits ``CONFLICTING_CONTEXT`` when pipeline and transaction
138
+ contexts collide (in both ``FiberPipelineHandler``/
139
+ ``FiberTransactionHandler`` and the
140
+ ``execute_transaction``/``execute_pipeline`` entry points), and
141
+ ``FAST_WRITER_BLOCKED`` when a fast writer (``field!``) is called
142
+ inside a transaction or pipeline. These fire just before the
143
+ corresponding ``ConflictingContextError`` /
144
+ ``OperationModeError`` is raised, so operators can pinpoint where
145
+ blocked operations originate when ``FAMILIA_TRACE=1``.
146
+
147
+ Changed
148
+ -------
149
+
150
+ - ``repair_all!`` now runs each repair stage inside its own rescue
151
+ boundary; a failure in one dimension no longer prevents the others
152
+ from running. The return hash gains ``:status`` (``:ok`` or
153
+ ``:partial_failure``), ``:errors`` (per-stage exception details
154
+ when raised), and ``:multi_indexes`` (results from the new
155
+ ``repair_multi_indexes!``). An opt-in ``verify: true`` kwarg
156
+ re-runs ``health_check`` after repair and exposes the result as
157
+ ``:post_audit`` / ``:verified`` so callers can confirm the run
158
+ actually drove the model back to a healthy state.
159
+
160
+ - ``AuditReport#complete?`` is no longer false-positive due to
161
+ ``:not_implemented`` stubs in ``multi_indexes`` -- instance-scoped
162
+ indexes return ``:ok`` or ``:issues_found`` like class-level ones.
163
+
164
+ - ``housekeeping`` feature: split the dual-purpose ``tidy!`` into two
165
+ explicit instance methods. ``do_chore!(name)`` runs a single named
166
+ chore and returns the block's raw return value (no longer wrapped
167
+ in a ``{name => result}`` hash). ``do_chores!`` runs every
168
+ registered chore and returns the ``{name => result}`` hash.
169
+ ``tidy!`` is preserved as an alias of ``do_chores!`` for backwards
170
+ compatibility with the 2.7.0 no-arg call site; the single-arg form
171
+ ``tidy!(:name)`` now raises ``ArgumentError``.
172
+
173
+ - The connection handler hierarchy has been refactored from class
174
+ inheritance (``BaseConnectionHandler``) to module composition.
175
+ Handlers now ``include Familia::Connection::Handler`` and declare
176
+ their operation-mode capabilities with a small DSL:
177
+ ``supports transaction: true, pipelined: false``. The
178
+ ``BaseConnectionHandler`` constant is gone. This is only relevant if
179
+ you have custom handlers in application code — the public
180
+ ``allows_transaction`` / ``allows_pipelined`` class methods continue
181
+ to work, and the singleton ``.instance`` accessors on
182
+ ``FiberPipelineHandler`` / ``FiberTransactionHandler`` are
183
+ unchanged. The previous default of "allow all operations" when
184
+ capability flags were not set has been removed; every handler is now
185
+ expected to declare its capabilities explicitly via ``supports``.
186
+ - ``Familia.dbclient`` and ``Familia::DataType#dbclient`` now route through ``FiberPipelineHandler`` before ``FiberTransactionHandler``, matching ``Horreum#dbclient``. With both handlers in the chain, attempting to mix pipeline and transaction contexts raises ``Familia::ConflictingContextError`` uniformly from every call site.
187
+
188
+ Removed
189
+ -------
190
+
191
+ - ``Familia::DataType#direct_access`` has been removed. The method
192
+ was a legacy escape hatch for issuing raw Redis commands from
193
+ inside a DataType wrapper; it predates the chain-based routing of
194
+ ``Fiber[:familia_transaction]`` and ``Fiber[:familia_pipeline]``.
195
+ All in-tree call sites now go through the wrapper's own mutating
196
+ methods (which auto-route through the active transaction or
197
+ pipeline) or through the wrapper's ``transaction`` / ``pipelined``
198
+ blocks. If you were calling ``direct_access do |conn, key| ... end``,
199
+ replace it with either the DataType's own mutator or the
200
+ corresponding block API.
201
+
202
+ Fixed
203
+ -----
204
+
205
+ - ``SortedSet#popmin`` and ``SortedSet#popmax`` now normalize an explicitly
206
+ passed ``nil`` count to the default of ``1``. Previously, calling
207
+ ``zset.popmin(nil)`` or ``zset.popmax(nil)`` would bypass the ``count == 1``
208
+ branch of the structural dispatch added in the prior commit, causing
209
+ redis-rb's flat ``[member, score]`` return shape to be iterated as if it
210
+ were a nested result — yielding a malformed pair. Omitting the argument
211
+ was and remains unaffected.
212
+
213
+ - Restored ``require 'set'`` in ``lib/familia/horreum/management/audit.rb``. ``Set`` is autoloaded as a core class only on Ruby 3.4+; on Ruby 3.2/3.3 (the gem's supported floor) the require is mandatory for the five ``Set.new`` usages in that file.
214
+
215
+ AI Assistance
216
+ -------------
217
+
218
+ - Claude Opus 4.5 analyzed Redis 7 command documentation and compared coverage
219
+ against existing Familia DataType implementations using parallel Explore agents.
220
+ - Implementation performed by 5 parallel backend-dev agents, one per DataType.
221
+ - Test coverage written by 5 parallel qa-automation-engineer agents focusing on
222
+ Familia-specific behavior (serialization, deserialization, aliases) rather than
223
+ re-testing redis-rb gem functionality.
224
+
225
+ - Edge case identified by the Claude Code Review GitHub Action
226
+ (``.github/workflows/claude-code-review.yml``) when reviewing the
227
+ structural-dispatch change in commit ``010d5be``. Fix drafted and verified
228
+ by Claude Opus 4.7 under supervision.
229
+
230
+ - Instance-scoped multi-index audit algorithm (bucket discovery,
231
+ scope existence batching, participation-driven missing detection),
232
+ ``repair_multi_indexes!``, the ``repair_all!`` robustness
233
+ refactor, and the accompanying tryouts coverage were authored
234
+ with Claude Code assistance against the #217 review branch.
235
+
236
+ - Method split, alias wiring, bulk runner port from OTS, doc updates,
237
+ and expanded tryouts coverage (25 → 48 testcases) authored with
238
+ Claude Code.
239
+
240
+ - Added the trace instrumentation in response to PR #263 review
241
+ feedback (Claude Code review bot) recommending tracing for
242
+ conflict detection events.
243
+
244
+ - The handler refactor, ``direct_access`` removal, and changelog drafting were performed with Claude Code assistance while resolving review feedback on PR #263.
245
+
10
246
  .. _changelog-2.7.0:
11
247
 
12
248
  2.7.0 — 2026-05-13
data/Gemfile.lock CHANGED
@@ -1,14 +1,14 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.7.0)
4
+ familia (2.9.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (>= 2.4, < 4.0)
7
7
  csv (~> 3.3)
8
8
  json_schemer (~> 2.0)
9
9
  logger (~> 1.7)
10
10
  oj (~> 3.16)
11
- redis (>= 4.8.1, < 6.0)
11
+ redis (>= 5.0, < 6.0)
12
12
  stringio (~> 3.1.1)
13
13
  uri-valkey (~> 1.4)
14
14
 
@@ -3,7 +3,7 @@
3
3
  The Housekeeping feature provides a declarative DSL for registering named cleanup chores on Horreum models. It is designed for short-lived, repeated tidying against fields whose values have drifted over time -- not for versioned, one-shot migrations.
4
4
 
5
5
  > [!TIP]
6
- > Enable with `feature :housekeeping` and register cleanup blocks with `chore :name do |obj| ... end`. Run them with `obj.tidy!`. Iteration and persistence are the caller's responsibility.
6
+ > Enable with `feature :housekeeping` and register cleanup blocks with `chore :name do |obj| ... end`. Run all of them with `obj.do_chores!` (aliased `tidy!`), or one with `obj.do_chore!(:name)`. Iteration and persistence are the caller's responsibility.
7
7
 
8
8
  ## Quick Start
9
9
 
@@ -27,8 +27,12 @@ class Organization < Familia::Horreum
27
27
  end
28
28
 
29
29
  org = Organization.from_identifier("acme-corp")
30
- org.tidy!
30
+ org.do_chores!
31
31
  # => { standardize_planid: true }
32
+
33
+ # Or run a single chore by name (returns the block's raw value):
34
+ org.do_chore!(:standardize_planid)
35
+ # => true
32
36
  ```
33
37
 
34
38
  ## When to Use
@@ -79,35 +83,43 @@ Run all registered chores, or one by name:
79
83
  ```ruby
80
84
  user = User.from_identifier("alice@example.com")
81
85
 
82
- user.tidy!
86
+ user.do_chores!
83
87
  # => { downcase_email: true, default_timezone: nil }
84
88
 
85
- user.tidy!(:downcase_email)
86
- # => { downcase_email: true }
89
+ user.tidy! # alias for do_chores!
90
+ # => { downcase_email: nil, default_timezone: nil }
91
+
92
+ user.do_chore!(:downcase_email)
93
+ # => true
87
94
  ```
88
95
 
89
- The return value is a hash mapping chore name to the block's return value. A truthy result signals "modified"; `nil` or `false` signals "no-op". The feature does not interpret these values -- they are passed through for the caller's stats collection.
96
+ `do_chores!` returns a hash mapping chore name to the block's return value. `do_chore!` returns the block's raw return value (not wrapped in a hash). A truthy result signals "modified"; `nil` or `false` signals "no-op". The feature does not interpret these values -- they are passed through for the caller's stats collection.
90
97
 
91
- ### Iteration -- Caller's Responsibility
98
+ ### Iteration -- Bulk Runner
92
99
 
93
- The feature operates on a single instance. Bulk runs live in the consumer app:
100
+ For running chores across every record, the feature ships a class-level `run_chores!` that iterates the `instances` collection in pipelined batches (via `load_multi`), executes each chore per record with error isolation, and returns a stats hash:
94
101
 
95
102
  ```ruby
96
- # nightly rake task
97
- namespace :data do
98
- task tidy_orgs: :environment do
99
- stats = Hash.new(0)
100
- Organization.instances.each do |id|
101
- org = Organization.find_by_id(id) or next
102
- results = org.tidy!
103
- results.each { |name, result| stats[name] += 1 if result }
104
- end
105
- puts stats.inspect
106
- end
107
- end
103
+ Organization.run_chores!
104
+ # => {
105
+ # model: "Organization",
106
+ # scanned: 4200,
107
+ # chores: {
108
+ # standardize_planid: { modified: 37, errors: 0 },
109
+ # uppercase_country: { modified: 102, errors: 1 },
110
+ # },
111
+ # }
112
+
113
+ Organization.run_chores!(chore_name: :standardize_planid, limit: 500)
114
+ # Filter to one chore and cap records scanned.
115
+
116
+ Organization.run_chores!(batch_size: 50)
117
+ # Tune the load_multi pipeline batch size (default: 100).
108
118
  ```
109
119
 
110
- The feature has no opinion about batching, SCAN vs KEYS, error aggregation, or scheduling -- the consumer app owns all of that.
120
+ A truthy chore return increments `modified`; a raised exception increments `errors` (logged via `Familia.warn`) and iteration continues. The runner requires the class to expose `instances` (Horreum's default class-level sorted set) and `load_multi`.
121
+
122
+ For scheduling, cross-model orchestration, custom logging, or non-default iteration (e.g. a configured allowlist of model classes), wrap `run_chores!` in your own job. The feature deliberately stays out of cron, multi-model discovery, and project-specific logging layers.
111
123
 
112
124
  ## Generated Method Reference
113
125
 
@@ -117,15 +129,18 @@ The feature has no opinion about batching, SCAN vs KEYS, error aggregation, or s
117
129
  |-------|--------|---------|
118
130
  | **Class** | `chore(name, &block)` | Register a chore |
119
131
  | | `chores` | Hash of registered chores |
120
- | **Instance** | `tidy!(name = nil)` | Run all (or one) chore; returns Hash |
132
+ | | `run_chores!(chore_name:, limit:, batch_size:)` | Bulk runner across `instances`; returns stats hash |
133
+ | **Instance** | `do_chore!(name)` | Run a single chore by name; returns the block's raw value |
134
+ | | `do_chores!` | Run every registered chore; returns Hash |
135
+ | | `tidy!` | Alias for `do_chores!` |
121
136
 
122
137
  ## Design Constraints
123
138
 
124
139
  1. **No implicit saves.** The block must call `save` (or `commit_fields`) itself. The feature does not auto-persist.
125
- 2. **No iteration.** Operates on a single instance. There is no class-level `tidy_all!`.
140
+ 2. **Bulk via `run_chores!` only.** The feature operates on a single instance (`do_chore!`/`do_chores!`) plus one bulk runner (`run_chores!`) that iterates `instances`. Scheduling, multi-model orchestration, and custom logging stay in the consumer app.
126
141
  3. **No ordering.** Chores run in registration order, but should not depend on each other. If order matters, write one chore with sequential steps.
127
142
  4. **Idempotent by convention.** Use the conditional pattern (`if canonical && canonical != org.planid`) so a second run is a no-op.
128
- 5. **Errors propagate.** The block can raise; the iteration code in the consumer app decides whether to rescue.
143
+ 5. **Errors isolate in `run_chores!`, propagate in `do_chore!`/`do_chores!`.** Single-instance methods let exceptions propagate; the bulk runner rescues per-record and increments the chore's `errors` counter so one failure doesn't halt the run.
129
144
 
130
145
  ## Common Patterns
131
146
 
@@ -150,7 +165,7 @@ class Customer < Familia::Horreum
150
165
  end
151
166
  end
152
167
 
153
- customer.tidy!
168
+ customer.do_chores!
154
169
  # => { trim_whitespace: true, uppercase_country: nil }
155
170
  ```
156
171
 
@@ -176,28 +191,43 @@ chore :reconcile_billing do |account|
176
191
  end
177
192
  ```
178
193
 
179
- ### Tracking Modified Records
194
+ ### Tracking Modified Records (Bulk)
195
+
196
+ `run_chores!` already aggregates `modified` and `errors` counts per chore. Use it directly:
197
+
198
+ ```ruby
199
+ report = Organization.run_chores!
200
+ report[:chores].each do |name, counts|
201
+ puts "#{name}: #{counts[:modified]} modified, #{counts[:errors]} errors"
202
+ end
203
+ ```
204
+
205
+ ### Custom Iteration (e.g. SCAN-Based)
206
+
207
+ If `instances`-driven iteration isn't suitable (sharded data, custom scoping), drop down to `do_chore!`/`do_chores!`:
180
208
 
181
209
  ```ruby
182
210
  modified = []
183
211
  Organization.instances.each do |id|
184
- org = Organization.find_by_id(id) or next
185
- results = org.tidy!
212
+ org = Organization.find_by_identifier(id) or next
213
+ results = org.do_chores!
186
214
  modified << id if results.values.any?
187
215
  end
188
216
  puts "Modified #{modified.size} records: #{modified.inspect}"
189
217
  ```
190
218
 
191
- ### Error Aggregation
219
+ ### Wrapping `run_chores!` for a Job Framework
192
220
 
193
221
  ```ruby
194
- errors = {}
195
- Organization.instances.each do |id|
196
- org = Organization.find_by_id(id) or next
197
- begin
198
- org.tidy!
199
- rescue => e
200
- errors[id] = e.message
222
+ class HousekeepingJob
223
+ def self.perform_for(klass)
224
+ report = klass.run_chores!(batch_size: 50)
225
+ StatsD.gauge("housekeeping.#{klass.name}.scanned", report[:scanned])
226
+ report[:chores].each do |chore, counts|
227
+ StatsD.increment("housekeeping.#{klass.name}.#{chore}.modified", counts[:modified])
228
+ StatsD.increment("housekeeping.#{klass.name}.#{chore}.errors", counts[:errors])
229
+ end
230
+ report
201
231
  end
202
232
  end
203
233
  ```
@@ -0,0 +1,125 @@
1
+ # Migrating to Familia 2.9.0
2
+
3
+ This version introduces batch iteration primitives for DataTypes, enabling efficient enumeration over large Redis collections. It also includes breaking changes to method names for clarity.
4
+
5
+ ## Breaking Changes
6
+
7
+ ### Method Renames
8
+
9
+ The multi-field update methods have been renamed to better reflect their purpose:
10
+
11
+ ```ruby
12
+ # Before (2.8.x)
13
+ user.batch_update(name: "Alice", email: "alice@example.com")
14
+ user.batch_fast_write(name: "Alice", email: "alice@example.com")
15
+
16
+ # After (2.9.0)
17
+ user.multi_field_update(name: "Alice", email: "alice@example.com")
18
+ user.multi_field_fast_write(name: "Alice", email: "alice@example.com")
19
+ ```
20
+
21
+ **Migration**: Find and replace `batch_update` with `multi_field_update` and `batch_fast_write` with `multi_field_fast_write`.
22
+
23
+ ### MultiResult Namespace
24
+
25
+ `MultiResult` has moved into the Familia namespace:
26
+
27
+ ```ruby
28
+ # Before (2.8.x)
29
+ result.is_a?(MultiResult)
30
+
31
+ # After (2.9.0)
32
+ result.is_a?(Familia::MultiResult)
33
+ ```
34
+
35
+ **Migration**: Replace bare `MultiResult` references with `Familia::MultiResult`.
36
+
37
+ ## New Features
38
+
39
+ ### Enumerable Integration
40
+
41
+ All collection DataTypes now include Ruby's `Enumerable` module, providing `each_slice`, `lazy`, `map`, `reduce`, `find`, and other stdlib methods:
42
+
43
+ ```ruby
44
+ # Lazy iteration with transformation
45
+ Org.instances.lazy.map { |id| id.upcase }.take(10).to_a
46
+
47
+ # Batch processing with each_slice
48
+ User.instances.each_slice(100) do |batch|
49
+ batch.each { |id| process(id) }
50
+ end
51
+ ```
52
+
53
+ ### Filtered Iteration
54
+
55
+ Each DataType now supports type-specific filters on `each`:
56
+
57
+ ```ruby
58
+ # SortedSet: filter by score (timestamp) bounds
59
+ Org.instances.each(since: 24.hours.ago, until: Time.now) do |id|
60
+ puts id
61
+ end
62
+
63
+ # HashKey: filter by field name pattern
64
+ user.profile.each(matching: "pref_*") do |field, value|
65
+ puts "#{field}: #{value}"
66
+ end
67
+
68
+ # UnsortedSet: filter by member pattern
69
+ user.tags.each(matching: "admin*") do |tag|
70
+ puts tag
71
+ end
72
+ ```
73
+
74
+ ### each_record for Loading Horreum Instances
75
+
76
+ `each_record` yields fully-loaded Horreum records instead of raw IDs:
77
+
78
+ ```ruby
79
+ # Load records in batches of 100
80
+ Org.instances.each_record(batch_size: 100) do |org|
81
+ org.tidy! # org is a loaded Horreum instance
82
+ end
83
+
84
+ # Control pipelining depth separately from fetch batch size
85
+ Org.instances.each_record(batch_size: 500, write_size: 50) do |org|
86
+ org.status!("active")
87
+ end
88
+
89
+ # Serial execution (no pipelining)
90
+ Org.instances.each_record(batch_size: 100, write_size: nil) do |org|
91
+ org.complex_operation
92
+ end
93
+ ```
94
+
95
+ Ghost instances (keys that expired but remain in the `instances` sorted set) are automatically filtered and never reach the block.
96
+
97
+ ### BatchResult for Aggregated Operations
98
+
99
+ `Familia::BatchResult` aggregates results from batch operations with per-record error isolation:
100
+
101
+ ```ruby
102
+ result = Familia::BatchResult.collect(
103
+ Org.instances.each_record(batch_size: 100, since: 24.hours.ago)
104
+ ) do |org|
105
+ org.tidy!
106
+ end
107
+
108
+ result.scanned # Total records yielded to block
109
+ result.modified # Count of truthy block returns
110
+ result.errors # Array of {id:, error:} for failed records
111
+ result.duration_ms # Total execution time
112
+
113
+ # Re-raise errors after completion
114
+ result = Familia::BatchResult.collect(enum, strict: true) { |r| r.process! }
115
+ ```
116
+
117
+ ## Concurrent Mutation Behavior
118
+
119
+ When iterating with `each` or `each_record`, be aware of Redis cursor semantics:
120
+
121
+ - Items present from iteration start to end are guaranteed to be returned
122
+ - Items added or removed mid-iteration may or may not appear
123
+ - Blocks should be idempotent to handle potential duplicates
124
+
125
+ This is inherent to ZSCAN/HSCAN/SSCAN and is documented, not a bug.
data/familia.gemspec CHANGED
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.add_dependency 'json_schemer', '~> 2.0'
26
26
  spec.add_dependency 'logger', '~> 1.7'
27
27
  spec.add_dependency 'oj', '~> 3.16'
28
- spec.add_dependency 'redis', '>= 4.8.1', '< 6.0'
28
+ spec.add_dependency 'redis', '>= 5.0', '< 6.0'
29
29
  spec.add_dependency 'stringio', '~> 3.1.1'
30
30
  spec.add_dependency 'uri-valkey', '~> 1.4'
31
31