hecks 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/hecks +13 -0
- data/lib/hecks/adapters/driven/memory.rb +1 -1
- data/lib/hecks/adapters/driven/postgres.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
- data/lib/hecks/behaviors/expectations.rb +2 -2
- data/lib/hecks/bluebook/assembly/contract.rb +1 -1
- data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
- data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
- data/lib/hecks/bluebook/assembly.rb +1 -1
- data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
- data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
- data/lib/hecks/bluebook/capabilities.rb +2 -2
- data/lib/hecks/bluebook/chapter.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
- data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
- data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
- data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +5 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
- data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
- data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
- data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
- data/lib/hecks/bluebook/meta_validator.rb +1 -1
- data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
- data/lib/hecks/bluebook/model_check.rb +12 -13
- data/lib/hecks/cache_dir.rb +103 -0
- data/lib/hecks/cli/document.rb +104 -0
- data/lib/hecks/cli/ir.rb +47 -0
- data/lib/hecks/cli/mcp.rb +32 -0
- data/lib/hecks/cli/mcp_door.rb +461 -0
- data/lib/hecks/cli/model_check.rb +270 -0
- data/lib/hecks/cli/project_cli.rb +123 -0
- data/lib/hecks/cli/project_diagrams.rb +43 -0
- data/lib/hecks/cli/run.rb +219 -0
- data/lib/hecks/cli/smoke_test.rb +59 -0
- data/lib/hecks/cli/stores.rb +54 -0
- data/lib/hecks/cli.rb +166 -0
- data/lib/hecks/facade/handle.rb +2 -2
- data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
- data/lib/hecks/mcp_door_scope.rb +176 -0
- data/lib/hecks/ports/persistence/append_only.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +12 -6
- data/lib/hecks/ports/persistence/state_codec.rb +1 -1
- data/lib/hecks/projections/deploy/fargate.rb +1 -1
- data/lib/hecks/projections/deploy/lambda.rb +3 -3
- data/lib/hecks/projections/reference.rb +7 -1
- data/lib/hecks/projector/exporter.rb +4 -4
- data/lib/hecks/projector/target.rb +1 -1
- data/lib/hecks/release/runner/ci_publisher.rb +96 -0
- data/lib/hecks/release/runner/commands.rb +68 -0
- data/lib/hecks/release/runner/console.rb +52 -0
- data/lib/hecks/release/runner/gem_publisher.rb +44 -0
- data/lib/hecks/release/runner/git.rb +48 -0
- data/lib/hecks/release/runner/npm_publisher.rb +89 -0
- data/lib/hecks/release/runner/preflight.rb +119 -0
- data/lib/hecks/release/runner/published.rb +55 -0
- data/lib/hecks/release/runner/tagger.rb +87 -0
- data/lib/hecks/release/runner.rb +244 -0
- data/lib/hecks/router/namespace_installer.rb +1 -1
- data/lib/hecks/runtime/command_interpreter.rb +3 -3
- data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
- data/lib/hecks/runtime/entity_element.rb +1 -1
- data/lib/hecks/runtime/entity_interpreter.rb +1 -1
- data/lib/hecks/runtime/invocation.rb +1 -1
- data/lib/hecks/runtime/loader.rb +1 -1
- data/lib/hecks/runtime/outbox.rb +1 -1
- data/lib/hecks/runtime/reaction_invocation.rb +3 -3
- data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
- data/lib/hecks/runtime/reference_hop.rb +4 -4
- data/lib/hecks/runtime/registry/verification.rb +11 -11
- data/lib/hecks/runtime/registry.rb +8 -4
- data/lib/hecks/runtime/saga_interpreter.rb +1 -1
- data/lib/hecks/runtime/value/coercion.rb +3 -3
- data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
- data/lib/hecks/storehouse.rb +18 -12
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +5 -0
- data/lib/hecks.rb +6 -1
- metadata +28 -58
- data/lib/hecks/bench/cli.rb +0 -74
- data/lib/hecks/bench/environment.rb +0 -100
- data/lib/hecks/bench/postgres_probe.rb +0 -41
- data/lib/hecks/bench/report.rb +0 -116
- data/lib/hecks/bench/ruby_runner.rb +0 -151
- data/lib/hecks/bench/run.rb +0 -55
- data/lib/hecks/bench/rust_runner.rb +0 -165
- data/lib/hecks/bench/stats.rb +0 -75
- data/lib/hecks/bench/suite.rb +0 -172
- data/lib/hecks/bench/workload.rb +0 -137
- data/lib/hecks/bench.rb +0 -28
- data/lib/hecks/codemod.rb +0 -443
- data/lib/hecks/corpus.rb +0 -479
- data/lib/hecks/doc/reference.rb +0 -585
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
- data/lib/hecks/fuzzing/combination_miner.rb +0 -178
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
- data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
- data/lib/hecks/fuzzing/differential.rb +0 -192
- data/lib/hecks/fuzzing/domain_generator.rb +0 -871
- data/lib/hecks/fuzzing/era_boundary.rb +0 -154
- data/lib/hecks/fuzzing/form_census.rb +0 -285
- data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
- data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
- data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
- data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
- data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
- data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
- data/lib/hecks/fuzzing/properties/guards.rb +0 -398
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
- data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
- data/lib/hecks/fuzzing/properties/querying.rb +0 -260
- data/lib/hecks/fuzzing/properties.rb +0 -267
- data/lib/hecks/fuzzing/qa_settings.rb +0 -164
- data/lib/hecks/fuzzing/replay.rb +0 -930
- data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
- data/lib/hecks/fuzzing/self_consistency.rb +0 -825
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
- data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
- data/lib/hecks/fuzzing/shrinker.rb +0 -309
- data/lib/hecks/fuzzing/structural_skips.rb +0 -53
- data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
- data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
- data/lib/hecks/fuzzing/value_generator.rb +0 -340
- data/lib/hecks/fuzzing.rb +0 -23
- data/lib/hecks/grammar/evolve.rb +0 -524
- data/lib/hecks/query_ir.rb +0 -482
|
@@ -1,825 +0,0 @@
|
|
|
1
|
-
require "json"
|
|
2
|
-
require "tmpdir"
|
|
3
|
-
require "open3"
|
|
4
|
-
require_relative "../adapters/driven/heki"
|
|
5
|
-
require_relative "../runtime/saga_interpreter"
|
|
6
|
-
|
|
7
|
-
module Hecks
|
|
8
|
-
module Fuzzing
|
|
9
|
-
# **A second comparison axis** — every other check this practice runs is
|
|
10
|
-
# differential: Ruby vs the compiled Rust conformance binary, on the
|
|
11
|
-
# same generated sequence (`bin/qa_sweep`'s own `diff_ruby_vs_rust`,
|
|
12
|
-
# `Properties.check`). Differential comparison structurally cannot
|
|
13
|
-
# catch a bug where both engines are wrong the same way, or where a
|
|
14
|
-
# single engine is self-inconsistent with nothing to differentially
|
|
15
|
-
# compare it against. This module asks a different question of one
|
|
16
|
-
# engine at a time: does it agree with itself?
|
|
17
|
-
#
|
|
18
|
-
# ## Three checks
|
|
19
|
-
#
|
|
20
|
-
# Run on the same already-generated sequence and its resulting live
|
|
21
|
-
# state — no second fuzzing pass, no re-dispatch through the command
|
|
22
|
-
# layer:
|
|
23
|
-
#
|
|
24
|
-
# 1. `check_rehydration` — does reloading an aggregate from its
|
|
25
|
-
# own durable journal reproduce exactly the state a live dispatch
|
|
26
|
-
# already produced?
|
|
27
|
-
# 2. `check_idempotency` — does replaying that same journal a
|
|
28
|
-
# second time change anything? (a variant of #1, but a different
|
|
29
|
-
# failure mode: no leaked state between applications, not just
|
|
30
|
-
# "cold load works once.")
|
|
31
|
-
# 3. `check_value_object_round_trip` — does every value object the
|
|
32
|
-
# sequence actually built survive `to_json` then rebuilt back?
|
|
33
|
-
#
|
|
34
|
-
# ## The rehydration path, found, not guessed
|
|
35
|
-
#
|
|
36
|
-
# hecks is not event-sourced at the aggregate level — there is no
|
|
37
|
-
# `AccountOpened`-shaped log a `CommandInterpreter` folds to rebuild
|
|
38
|
-
# state. What there is, real and already shipping, is
|
|
39
|
-
# `Ports::Persistence::AppendOnly` (lib/hecks/ports/persistence/
|
|
40
|
-
# append_only.rb): every adapter accepts the same `Entry` stream
|
|
41
|
-
# (`operation`, `id`, the full state after that command — not a
|
|
42
|
-
# delta) and answers `#entries`; `#recover!` — "an append is durable
|
|
43
|
-
# before a projection is attempted; replaying the log restores a
|
|
44
|
-
# snapshot/table after a crash in that small window" — is called on
|
|
45
|
-
# every repository this runtime ever builds (`RepositoryFactory
|
|
46
|
-
# .build`'s own `recover: true` default). That is the production
|
|
47
|
-
# cold-rehydration path. Reusing `#recover!` directly against the
|
|
48
|
-
# live adapter would prove nothing, though: `Fuzzing::Replay` runs
|
|
49
|
-
# against `Adapters::Memory` (`IsolatedBoot`'s own default), and
|
|
50
|
-
# Memory's own `Entry#state` is a shallow `instance.state.dup` — the
|
|
51
|
-
# exact same `Runtime::Value` objects a command produced ride along
|
|
52
|
-
# unchanged, so folding them straight back through Memory's own
|
|
53
|
-
# `#project` is a tautology that can never fail (`Value#for_attribute`
|
|
54
|
-
# 's own `value.is_a?(self) && value.type_name == ...` branch passes
|
|
55
|
-
# an already-typed value straight through, no rebuilding at all).
|
|
56
|
-
#
|
|
57
|
-
# So `fold!`, below, feeds the same entries through `Adapters::Heki`
|
|
58
|
-
# instead — a real, already-shipped, disk-backed adapter
|
|
59
|
-
# (examples/banking's own `persisted_by("Heki")`), in a throwaway
|
|
60
|
-
# directory. Writing forces every value through `JSON.generate`
|
|
61
|
-
# (Heki's own journal line, its own compressed snapshot); reading
|
|
62
|
-
# back through a fresh `Heki` instance (unmemoized `@store`) forces
|
|
63
|
-
# `read_snapshot`/`replay_journal` — real `JSON.parse`, real
|
|
64
|
-
# `Zlib::Inflate`, real bytes off a real filesystem — which is what
|
|
65
|
-
# makes `Instance.hydrate_with_defaults` → `Value.hydrate` →
|
|
66
|
-
# `Value.for_attribute` actually rebuild every value object from raw
|
|
67
|
-
# data via `Value.build`, the same coercion/validation path a real
|
|
68
|
-
# restart takes, rather than pass the live object through unchanged.
|
|
69
|
-
# This is the exact mechanism `AppendOnly#recover!` names in its own
|
|
70
|
-
# comment ("restores a snapshot/table after a crash"), just exercised
|
|
71
|
-
# against the one adapter whose own `#entries` actually forces the
|
|
72
|
-
# JSON boundary Memory's does not.
|
|
73
|
-
module SelfConsistency
|
|
74
|
-
module_function
|
|
75
|
-
|
|
76
|
-
# Every Heki this pass opens is guarded like a runtime repository
|
|
77
|
-
# (Phase 2, Track A, PR A4). These adapters are built directly, not
|
|
78
|
-
# through `RepositoryFactory.build`, so without this a cold read here
|
|
79
|
-
# could hand `Runtime::Instance` undecoded state and the codec's
|
|
80
|
-
# guarantee would have a bypass in exactly the check that exists to
|
|
81
|
-
# compare stored state against live state.
|
|
82
|
-
#
|
|
83
|
-
# @param aggregate [Bluebook::Aggregate] the aggregate this store holds
|
|
84
|
-
# @param settings [Hash] adapter settings, forwarded to `Adapters::Heki.new`
|
|
85
|
-
# @param root [String, nil] the directory this store reads/writes under
|
|
86
|
-
# @return [Adapters::Heki] a fresh, codec-boundary-guarded Heki instance
|
|
87
|
-
def guarded_heki(**) = Ports::Persistence::CodecBoundary.guard!(Adapters::Heki.new(**))
|
|
88
|
-
|
|
89
|
-
# The whole pass — called once, with the runtime still live (inside
|
|
90
|
-
# `Replay.call`'s own `IsolatedBoot.call` block, before the tmp
|
|
91
|
-
# directory and its adapters go out of scope) and the `history`
|
|
92
|
-
# `Replay.call` is about to return. Nothing here boots a second
|
|
93
|
-
# runtime or dispatches a single command; every check below reads
|
|
94
|
-
# data this one replay already produced. Just the three checks
|
|
95
|
-
# below, run and collected — kept as three independently callable
|
|
96
|
-
# methods (not fused into one shared fold) so a spec proving one
|
|
97
|
-
# check can fire never has to reason about the other two.
|
|
98
|
-
#
|
|
99
|
-
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
|
|
100
|
-
# booted runtime this replay just dispatched through
|
|
101
|
-
# @param history [Hash] the replayed history `Replay.call` is about to return
|
|
102
|
-
# @return [Hash{Symbol => Array<Hash>}] the five checks' own findings, keyed
|
|
103
|
-
# `:rehydration`, `:idempotency`, `:value_object_round_trip`,
|
|
104
|
-
# `:saga_rehydration`, `:saga_redelivery_idempotency` — each value an empty
|
|
105
|
-
# Array when that check found nothing
|
|
106
|
-
def check(runtime, history)
|
|
107
|
-
{ rehydration: check_rehydration(runtime), idempotency: check_idempotency(runtime),
|
|
108
|
-
value_object_round_trip: check_value_object_round_trip(history),
|
|
109
|
-
saga_rehydration: check_saga_rehydration(runtime, history),
|
|
110
|
-
saga_redelivery_idempotency: check_saga_idempotency(runtime, history) }
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# Check 1 — rehydrate-from-journal == live state.
|
|
114
|
-
#
|
|
115
|
-
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
|
|
116
|
-
# booted runtime this replay just dispatched through
|
|
117
|
-
# @return [Array<Hash>] one divergence Hash (`:field`, `:domain`, `:aggregate`,
|
|
118
|
-
# `:live`, `:rehydrated`) per aggregate whose cold-read state disagrees with its
|
|
119
|
-
# live state; empty when every touched aggregate rehydrates cleanly
|
|
120
|
-
def check_rehydration(runtime)
|
|
121
|
-
each_touched_repository(runtime).filter_map do |domain_name, aggregate, repository, entries|
|
|
122
|
-
live = snapshot(repository)
|
|
123
|
-
Dir.mktmpdir("hecks-self-consistency") do |tmp|
|
|
124
|
-
writer = guarded_heki(aggregate: aggregate, root: tmp)
|
|
125
|
-
rehydrated = fold!(writer, tmp, aggregate, entries)
|
|
126
|
-
next if rehydrated == live
|
|
127
|
-
|
|
128
|
-
{ field: "rehydration", domain: domain_name, aggregate: aggregate.hecks_name,
|
|
129
|
-
live: live, rehydrated: rehydrated }
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# Check 2 — replay idempotency: folding the same entries into the
|
|
135
|
-
# same durable store a second time must change nothing. A variant
|
|
136
|
-
# of check 1, worth stating separately — this catches a
|
|
137
|
-
# replay-specific bug (leaked state between applications, a
|
|
138
|
-
# double-applied effect) that a single, one-shot cold read could
|
|
139
|
-
# never see, even one that already agrees with live state.
|
|
140
|
-
#
|
|
141
|
-
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
|
|
142
|
-
# booted runtime this replay just dispatched through
|
|
143
|
-
# @return [Array<Hash>] one divergence Hash (`:field`, `:domain`, `:aggregate`,
|
|
144
|
-
# `:once`, `:twice`) per aggregate whose second fold disagrees with its first;
|
|
145
|
-
# empty when every touched aggregate folds idempotently
|
|
146
|
-
def check_idempotency(runtime)
|
|
147
|
-
each_touched_repository(runtime).filter_map do |domain_name, aggregate, repository, entries|
|
|
148
|
-
Dir.mktmpdir("hecks-self-consistency") do |tmp|
|
|
149
|
-
writer = guarded_heki(aggregate: aggregate, root: tmp)
|
|
150
|
-
once = fold!(writer, tmp, aggregate, entries)
|
|
151
|
-
twice = fold!(writer, tmp, aggregate, entries)
|
|
152
|
-
next if once == twice
|
|
153
|
-
|
|
154
|
-
{ field: "idempotency", domain: domain_name, aggregate: aggregate.hecks_name,
|
|
155
|
-
once: once, twice: twice }
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# Check 3 — every `Runtime::Value` the sequence actually built
|
|
161
|
-
# (walked out of the replay's own `instances`/`events`/`queries`,
|
|
162
|
-
# never a hand-picked example), round-tripped through the real
|
|
163
|
-
# serialize/deserialize pair: `Value#to_json` (JSON.generate(to_h),
|
|
164
|
-
# value.rb) out, `Value.build` (value/coercion.rb — the same
|
|
165
|
-
# constructor a command argument's own raw JSON goes through) back
|
|
166
|
-
# in. There is no class-level `VO.from_json` in this codebase (that
|
|
167
|
-
# spelling is Rust's — rust/src/exemplar/json.rs's generated
|
|
168
|
-
# `from_json` per closed set/value object); `Value.build` is the
|
|
169
|
-
# actual Ruby door a raw, untyped Hash becomes a validated,
|
|
170
|
-
# admitted, invariant-checked value object through.
|
|
171
|
-
# `aggregate:` threaded alongside every value found, not dropped —
|
|
172
|
-
# `Value.build(value_object, fields, aggregate)`'s third argument is
|
|
173
|
-
# what lets `normalize_composite_fields` resolve a nested composite
|
|
174
|
-
# field's own type by name (`value_object_for(aggregate, type)`).
|
|
175
|
-
# Building with `aggregate: nil` (this method's first version, live-
|
|
176
|
-
# tested against `examples/pizzas` while this was being written)
|
|
177
|
-
# silently skips that step entirely — `Pizza`'s own `price_cents`/
|
|
178
|
-
# `size` fields round-tripped back as bare, string-keyed Hashes
|
|
179
|
-
# instead of rebuilt `Money`/`PizzaSize` value objects, a false
|
|
180
|
-
# positive this check would have reported as a real bug on every
|
|
181
|
-
# single sweep. Resolved from `history[:instances]`' own key
|
|
182
|
-
# (`"Domain::Aggregate#id"`, `Replay#snapshot_instances`) and
|
|
183
|
-
# `history[:events]`' own `event[:aggregate]` (`"Domain::Aggregate"`,
|
|
184
|
-
# domain-qualified) against `history[:bluebooks]` — the exact same
|
|
185
|
-
# loaded chapter map every other replay-time check already reads
|
|
186
|
-
# off `history` rather than a second lookup. `history[:queries]`'
|
|
187
|
-
# own rows have no single owning aggregate reliably named on the
|
|
188
|
-
# entry itself (a cross-aggregate read model, a `for_each` target),
|
|
189
|
-
# so they are left out of this walk rather than risk the same false
|
|
190
|
-
# positive `nil` already produced once — `instances` and `events`
|
|
191
|
-
# alone already reach every value object a generated sequence
|
|
192
|
-
# actually persisted or announced.
|
|
193
|
-
#
|
|
194
|
-
# @param history [Hash] a replayed history as returned by `Replay.call`
|
|
195
|
-
# @return [Array<Hash>] one divergence Hash (`:field`, `:type`, `:original`, and
|
|
196
|
-
# either `:rehydrated` or `:error`) per value object whose `to_json`-then-`build`
|
|
197
|
-
# round trip disagrees with (or raises on) the original; empty when every value
|
|
198
|
-
# object found round-trips cleanly
|
|
199
|
-
def check_value_object_round_trip(history)
|
|
200
|
-
bluebooks = history[:bluebooks] || {}
|
|
201
|
-
seen = {}.compare_by_identity
|
|
202
|
-
found = []
|
|
203
|
-
|
|
204
|
-
history[:instances].each do |key, state|
|
|
205
|
-
domain_name, aggregate_name = key.to_s.split("#", 2).first.to_s.split("::", 2)
|
|
206
|
-
aggregate = bluebooks[domain_name]&.aggregate(aggregate_name)
|
|
207
|
-
walk_value_objects(state, found, seen, aggregate)
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
history[:events].each do |event|
|
|
211
|
-
domain_name, aggregate_name = event[:aggregate].to_s.split("::", 2)
|
|
212
|
-
aggregate = bluebooks[domain_name]&.aggregate(aggregate_name)
|
|
213
|
-
walk_value_objects(event[:payload], found, seen, aggregate)
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
found.filter_map do |value, aggregate|
|
|
217
|
-
begin
|
|
218
|
-
rebuilt = Runtime::Value.build(value.value_object, JSON.parse(value.to_json), aggregate)
|
|
219
|
-
rescue StandardError => e
|
|
220
|
-
next { field: "value_object_round_trip", type: value.type_name, original: value.to_h,
|
|
221
|
-
error: "#{e.class}: #{e.message}" }
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
next if rebuilt == value
|
|
225
|
-
|
|
226
|
-
{ field: "value_object_round_trip", type: value.type_name, original: value.to_h,
|
|
227
|
-
rehydrated: rebuilt.to_h }
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
# Check 4 — saga cold-rehydration (angle-10). Checks 1/2 above cold-
|
|
232
|
-
# read an aggregate's own journal through Heki; nothing in this file
|
|
233
|
-
# ever exercised the other durable store `SagaInterpreter#checkpoint`
|
|
234
|
-
# writes through — `Ports::Persistence::NullSagaStore`'s own header
|
|
235
|
-
# calls Heki's `SagaStore` (`adapters/driven/heki/saga_store.rb`) the
|
|
236
|
-
# optional saga-persistence capability, and it is real and already
|
|
237
|
-
# shipping, just never fuzzed: `Registry#rehydrate_sagas!` — the
|
|
238
|
-
# production "process just restarted" path — folds exactly what
|
|
239
|
-
# `each_saga` yields back into `@saga_instances`, and until now
|
|
240
|
-
# nothing ever proved that round trip faithful for a sequence this
|
|
241
|
-
# practice actually generated. `BUG#6`/`#9`/`#10` all came out of
|
|
242
|
-
# this exact interpreter, which is why this checks it specifically
|
|
243
|
-
# rather than folding it into checks 1/2's own aggregate walk.
|
|
244
|
-
#
|
|
245
|
-
# `history[:saga_instances]` (`replay.rb`'s own `saga_instances`
|
|
246
|
-
# local, built once at the very end of a replay) is the same
|
|
247
|
-
# materialized `{pm_name => {correlation => {state:, memory:}}}`
|
|
248
|
-
# shape `SagaInterpreter#checkpoint` itself hands a real adapter —
|
|
249
|
-
# read from `history`, not re-derived from the (by-now-live, already
|
|
250
|
-
# mutated by whatever `check_saga_idempotency` ran first, see that
|
|
251
|
-
# method's own header) `runtime.registry.saga_instances`. Written
|
|
252
|
-
# through a real `Adapters::Heki` (a throwaway tmpdir, one per
|
|
253
|
-
# process manager so two process managers with correlations that
|
|
254
|
-
# happen to collide as strings never share a store), read back
|
|
255
|
-
# through a fresh instance (unmemoized `@store`/`@saga_store`, same
|
|
256
|
-
# reason `fold!` above uses one) — forcing the identical
|
|
257
|
-
# `JSON.generate`/`JSON.parse` boundary a real crash-then-restart
|
|
258
|
-
# takes, not a live-object pass-through.
|
|
259
|
-
#
|
|
260
|
-
# One finding per (domain, process manager) — every correlation this
|
|
261
|
-
# process manager's own `history[:saga_instances]` entry holds,
|
|
262
|
-
# compared as a whole Hash — the same aggregate-granularity (not
|
|
263
|
-
# per-record) `check_rehydration` already reports at.
|
|
264
|
-
#
|
|
265
|
-
# `completed_compensations` is deliberately out of scope — `history[
|
|
266
|
-
# :saga_instances]` never captures it (`replay.rb`'s own comment:
|
|
267
|
-
# only `state`/`memory` are threaded through, since a saga's
|
|
268
|
-
# in-flight compensation ledger is a fact about a leg still running,
|
|
269
|
-
# not the settled snapshot this history exists to describe), so
|
|
270
|
-
# there is no ground truth to compare it against here. Written as an
|
|
271
|
-
# empty array on the way in and never read back on the way out.
|
|
272
|
-
#
|
|
273
|
-
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
|
|
274
|
-
# booted runtime this replay just dispatched through
|
|
275
|
-
# @param history [Hash] a replayed history as returned by `Replay.call`
|
|
276
|
-
# @return [Array<Hash>] one divergence Hash (`:field`, `:domain`,
|
|
277
|
-
# `:process_manager`, `:live`, `:rehydrated`) per process manager whose cold-read
|
|
278
|
-
# saga instances disagree with the ones this replay persisted; empty when every
|
|
279
|
-
# process manager with persisted instances rehydrates cleanly
|
|
280
|
-
def check_saga_rehydration(runtime, history)
|
|
281
|
-
saga_instances = history[:saga_instances] || {}
|
|
282
|
-
each_domain_process_manager(runtime).filter_map do |domain_name, process_manager|
|
|
283
|
-
persisted = saga_instances[process_manager.name]
|
|
284
|
-
next if persisted.nil? || persisted.empty?
|
|
285
|
-
|
|
286
|
-
anchor = runtime.registry.bluebook(domain_name).aggregates.first
|
|
287
|
-
next unless anchor
|
|
288
|
-
|
|
289
|
-
Dir.mktmpdir("hecks-self-consistency-saga") do |tmp|
|
|
290
|
-
writer = guarded_heki(aggregate: anchor, root: tmp, settings: { domain: domain_name })
|
|
291
|
-
persisted.each do |correlation, saga|
|
|
292
|
-
writer.save_saga(process_manager: process_manager.name, correlation: correlation.to_s,
|
|
293
|
-
state: saga[:state], memory: saga[:memory], completed_compensations: [])
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
live = normalize_saga_rows(persisted)
|
|
297
|
-
rehydrated = cold_read_saga_rows(anchor, tmp, domain_name)
|
|
298
|
-
next if rehydrated == live
|
|
299
|
-
|
|
300
|
-
{ field: "saga_rehydration", domain: domain_name, process_manager: process_manager.name,
|
|
301
|
-
live: live, rehydrated: rehydrated }
|
|
302
|
-
end
|
|
303
|
-
end
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# Check 5 — redelivery idempotency of the checkpoint-then-load path.
|
|
307
|
-
# `check_saga_rehydration` above proves cold-reading a checkpoint
|
|
308
|
-
# reproduces the same data; this proves the other half of a real
|
|
309
|
-
# crash/restart — a message an at-least-once delivery mechanism (an
|
|
310
|
-
# outbox redrive, a queue redelivery) hands the rehydrated saga a
|
|
311
|
-
# second time — does not silently re-advance it. There is no flag
|
|
312
|
-
# for this in production (`SagaInterpreter#unwind`'s own comment:
|
|
313
|
-
# "the check is the guard") — the (event, current state) lookup
|
|
314
|
-
# `handler_for` performs is the entire mechanism, and it has never
|
|
315
|
-
# been exercised against a state this practice loaded from cold
|
|
316
|
-
# storage rather than one still sitting in a live process's memory.
|
|
317
|
-
#
|
|
318
|
-
# One (process manager, correlation) tested, using a fresh
|
|
319
|
-
# `Runtime::SagaInterpreter` sharing `runtime`'s own `registry` and
|
|
320
|
-
# `door: runtime` — the identical two objects the dispatcher's own
|
|
321
|
-
# `@sagas` was built from (`dispatcher.rb`'s own `SagaInterpreter.
|
|
322
|
-
# new(registry, door: self)`) — not a hand-rolled re-implementation
|
|
323
|
-
# of `advance_saga`'s own state-guard. `only: process_manager` scopes
|
|
324
|
-
# the redelivery to exactly the one procedure under test, the same
|
|
325
|
-
# keyword the outbox relay already uses to run one consumer alone
|
|
326
|
-
# (`Runtime::Outbox::Relay#run_consumer`).
|
|
327
|
-
#
|
|
328
|
-
# Which event to redeliver — `runtime.registry.saga_log`'s own last
|
|
329
|
-
# `advanced: true` row for this (process manager, correlation) names
|
|
330
|
-
# the event by name only; the real `Runtime::Event` object (payload,
|
|
331
|
-
# aggregate, id, `correlation` — everything `saga_correlation`/
|
|
332
|
-
# `dispatch_args` actually read) lives in `runtime.events`, still
|
|
333
|
-
# live for exactly this reason (this file's own header: "runtime is
|
|
334
|
-
# still live here"). Matched back by name plus `saga_correlation`
|
|
335
|
-
# itself (`Runtime::SagaInterpreter::Correlation`, `private`) —
|
|
336
|
-
# reused via `send` rather than reproduced, because reproducing its
|
|
337
|
-
# three-tier fallback (a dotted payload field, a stamped passthrough,
|
|
338
|
-
# a self-identifying `event.id`) here would be exactly the
|
|
339
|
-
# hand-rolled approximation this file was told not to build. A
|
|
340
|
-
# `:refused`-driven (compensating) transition is skipped outright —
|
|
341
|
-
# its own `saga_log` row's `on:` is the synthetic `REFUSED` trigger
|
|
342
|
-
# name, never a real domain event, so there is nothing to redeliver.
|
|
343
|
-
#
|
|
344
|
-
# Simulating "just restarted" — the live registry's own in-memory
|
|
345
|
-
# `saga_instances[pm][correlation]` slot is overwritten, in place,
|
|
346
|
-
# with whatever a cold Heki read of the same checkpoint answers
|
|
347
|
-
# (exactly what `Registry#rehydrate_sagas!` does for real on every
|
|
348
|
-
# boot), the redelivery is driven through the real interpreter, and
|
|
349
|
-
# the slot is put back — `ensure`d — once this correlation's own
|
|
350
|
-
# check is done. Safe only because `check`/`Replay.call` run this,
|
|
351
|
-
# synchronously, single-threaded, as the very last thing before
|
|
352
|
-
# `runtime` and its whole tmp directory go out of scope for good;
|
|
353
|
-
# nothing downstream of this method ever reads the live registry
|
|
354
|
-
# again (`check_saga_rehydration`, `check_rehydration`, `check_
|
|
355
|
-
# idempotency`, `check_value_object_round_trip` all read `history`'s
|
|
356
|
-
# own frozen snapshot instead, never `runtime.registry` — so calling
|
|
357
|
-
# order relative to this method's own mutation doesn't matter).
|
|
358
|
-
#
|
|
359
|
-
# The assertion is about `state`/`memory`, not "did a dispatch fire"
|
|
360
|
-
# — a leg whose own `from:`/`to:` are the same state (every existing
|
|
361
|
-
# saga's own starts_on self-transition, `waybill.bluebook`'s own leg
|
|
362
|
-
# 1/2) is expected to re-run on redelivery with no visible state
|
|
363
|
-
# change at all; that is a property of the declared handler graph,
|
|
364
|
-
# not a rehydration defect, and asserting against it here would
|
|
365
|
-
# manufacture a false positive on every saga this corpus has. A
|
|
366
|
-
# correlation whose current state has no declared handler at all for
|
|
367
|
-
# the redelivered event name (the ordinary, expected case once a
|
|
368
|
-
# saga has moved past the leg that produced its own current
|
|
369
|
-
# checkpoint) is exactly what this proves stays put.
|
|
370
|
-
#
|
|
371
|
-
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
|
|
372
|
-
# booted runtime this replay just dispatched through
|
|
373
|
-
# @param history [Hash] a replayed history as returned by `Replay.call`
|
|
374
|
-
# @return [Array<Hash>] one divergence Hash (`:field`, `:domain`,
|
|
375
|
-
# `:process_manager`, `:correlation`, `:on`, `:before`, `:after`) per correlation
|
|
376
|
-
# whose redelivered event visibly changed state/memory on a rehydrated
|
|
377
|
-
# checkpoint; empty when every redelivered correlation stayed put
|
|
378
|
-
def check_saga_idempotency(runtime, history)
|
|
379
|
-
saga_instances = history[:saga_instances] || {}
|
|
380
|
-
interpreter = Runtime::SagaInterpreter.new(runtime.registry, door: runtime)
|
|
381
|
-
|
|
382
|
-
each_domain_process_manager(runtime).flat_map do |domain_name, process_manager|
|
|
383
|
-
persisted = saga_instances[process_manager.name]
|
|
384
|
-
next [] if persisted.nil? || persisted.empty?
|
|
385
|
-
|
|
386
|
-
anchor = runtime.registry.bluebook(domain_name).aggregates.first
|
|
387
|
-
next [] unless anchor
|
|
388
|
-
|
|
389
|
-
persisted.filter_map do |correlation, saga|
|
|
390
|
-
redelivery = last_advancing_event(runtime, interpreter, process_manager, correlation)
|
|
391
|
-
next unless redelivery
|
|
392
|
-
|
|
393
|
-
check_one_saga_redelivery(runtime, interpreter, domain_name, process_manager, anchor,
|
|
394
|
-
correlation, saga, redelivery)
|
|
395
|
-
end
|
|
396
|
-
end
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
# ── Rust-side self-consistency ───────────────────────────────────
|
|
400
|
-
#
|
|
401
|
-
# The compiled binary's own rehydration door, already shipping —
|
|
402
|
-
# `kernel/cli.rs`'s `run` accepts an optional top-level `"seed"` key
|
|
403
|
-
# ("the exact 'Domain::Aggregate#id' -> state shape this run's own
|
|
404
|
-
# 'instances' output already produces... lets a host seed prior
|
|
405
|
-
# state back in instead of replaying `steps` from scratch every
|
|
406
|
-
# invocation"). `Store::from_seed`/`Store::instances` are that
|
|
407
|
-
# mechanism's own two halves — `rust/host` (docs/implemented/
|
|
408
|
-
# decisions/0012) depends on them being true inverses for real,
|
|
409
|
-
# today. `rust_seed_round_trip` exercises exactly that: a second,
|
|
410
|
-
# independent invocation of the same binary, `"steps": []` (nothing
|
|
411
|
-
# new dispatched — this is rehydration, not re-dispatch), seeded
|
|
412
|
-
# with whatever `"instances"` a prior invocation already produced.
|
|
413
|
-
# `differ` (unused, kept in the signature — see below) is a
|
|
414
|
-
# `RustConformanceHelpers`-including instance (`bin/qa_sweep`'s own
|
|
415
|
-
# `Differ`), the same argument `check_rust_rehydration`/`check_
|
|
416
|
-
# rust_idempotency` already take from their own callers; kept here
|
|
417
|
-
# rather than dropped from all three signatures at once so a
|
|
418
|
-
# future differential-style reduction has a door already open,
|
|
419
|
-
# without this module ever `require`ing `spec/support/` itself.
|
|
420
|
-
# Neither `strip_emitted_flags!` nor any other differential-only
|
|
421
|
-
# reduction runs here — that reduction exists so a Rust-only
|
|
422
|
-
# bookkeeping field (`emitted_<event>`, docs/decisions/0049) never
|
|
423
|
-
# counts against Ruby, which has no equivalent field to agree with
|
|
424
|
-
# at all (`RustConformanceHelpers#strip_emitted_flags!`'s own
|
|
425
|
-
# comment). This check has no Ruby side to spare — it is asking the
|
|
426
|
-
# Rust binary whether it agrees with itself, so `emitted_*` fields
|
|
427
|
-
# are exactly as real a fact to compare as any other. Stripping them
|
|
428
|
-
# here would silently delete them from the returned seed-round-trip
|
|
429
|
-
# result while leaving them present on the original `seed_instances`
|
|
430
|
-
# a caller passes in — an asymmetric comparison that would report
|
|
431
|
-
# every record carrying one as a rehydration divergence,
|
|
432
|
-
# unconditionally, on every domain that has one at all. Confirmed
|
|
433
|
-
# live against `examples/banking` (`Banking::Account`'s own
|
|
434
|
-
# `corrects` reaction) by comparing this method's own answer against
|
|
435
|
-
# the compiled binary's raw stdout for the identical seed call: the
|
|
436
|
-
# raw round trip preserves `emitted_fee_applied` correctly. `spec/
|
|
437
|
-
# self_consistency_rust_spec.rb`'s own "banking" example pins this
|
|
438
|
-
# against a real domain going forward.
|
|
439
|
-
#
|
|
440
|
-
# @param binary [String] path to the compiled Rust conformance binary
|
|
441
|
-
# @param _differ [Object] a `RustConformanceHelpers`-including instance, unused here
|
|
442
|
-
# @param seed_instances [Hash] the `"instances"` shape to seed the fresh invocation
|
|
443
|
-
# with, in place of replaying `steps`
|
|
444
|
-
# @return [Hash] the fresh invocation's own `"instances"`, or a
|
|
445
|
-
# `{"__self_consistency_error__" => String}` Hash if the binary exited nonzero or
|
|
446
|
-
# the binary itself reported an `"error"`
|
|
447
|
-
def rust_seed_round_trip(binary, _differ, seed_instances)
|
|
448
|
-
stdout, status = Open3.capture2(binary, stdin_data: JSON.generate({ "steps" => [], "seed" => seed_instances }))
|
|
449
|
-
return { "__self_consistency_error__" => "rust binary exited #{status.exitstatus}: #{stdout}" } \
|
|
450
|
-
unless status.success?
|
|
451
|
-
|
|
452
|
-
parsed = JSON.parse(stdout)
|
|
453
|
-
return { "__self_consistency_error__" => parsed["error"] } if parsed["error"]
|
|
454
|
-
|
|
455
|
-
parsed["instances"]
|
|
456
|
-
end
|
|
457
|
-
|
|
458
|
-
# Check 1, Rust side — seeding a fresh invocation with a prior
|
|
459
|
-
# invocation's own live `"instances"` must reproduce that same
|
|
460
|
-
# state, unchanged. `live_instances` is `rust_output["instances"]`
|
|
461
|
-
# — the exact same value `bin/qa_sweep`'s own differential compare
|
|
462
|
-
# already diffed against Ruby, reused here rather than re-derived.
|
|
463
|
-
#
|
|
464
|
-
# @param binary [String] path to the compiled Rust conformance binary
|
|
465
|
-
# @param differ [Object] a `RustConformanceHelpers`-including instance, forwarded to
|
|
466
|
-
# `rust_seed_round_trip` unused
|
|
467
|
-
# @param live_instances [Hash] the prior invocation's own `"instances"` output
|
|
468
|
-
# @return [Array<Hash>] a single divergence Hash (`:field`, `:live`, `:rehydrated`)
|
|
469
|
-
# if seeding with `live_instances` fails to reproduce it; empty otherwise
|
|
470
|
-
def check_rust_rehydration(binary, differ, live_instances)
|
|
471
|
-
rehydrated = rust_seed_round_trip(binary, differ, live_instances)
|
|
472
|
-
return [] if rehydrated == live_instances
|
|
473
|
-
|
|
474
|
-
[{ field: "rust_rehydration", live: live_instances, rehydrated: rehydrated }]
|
|
475
|
-
end
|
|
476
|
-
|
|
477
|
-
# Check 2, Rust side — seeding with what a first seed round trip
|
|
478
|
-
# already produced, a second time, must not drift any further. The
|
|
479
|
-
# same "replay it again, byte for byte" claim `check_idempotency`
|
|
480
|
-
# proves for Ruby, aimed at the one rehydration door this compiled
|
|
481
|
-
# binary actually has.
|
|
482
|
-
#
|
|
483
|
-
# @param binary [String] path to the compiled Rust conformance binary
|
|
484
|
-
# @param differ [Object] a `RustConformanceHelpers`-including instance, forwarded to
|
|
485
|
-
# `rust_seed_round_trip` unused
|
|
486
|
-
# @param live_instances [Hash] the prior invocation's own `"instances"` output to
|
|
487
|
-
# seed the first round trip with
|
|
488
|
-
# @return [Array<Hash>] a single divergence Hash (`:field`, `:once`, `:twice`) if a
|
|
489
|
-
# second seed round trip disagrees with the first; empty otherwise
|
|
490
|
-
def check_rust_idempotency(binary, differ, live_instances)
|
|
491
|
-
once = rust_seed_round_trip(binary, differ, live_instances)
|
|
492
|
-
twice = rust_seed_round_trip(binary, differ, once)
|
|
493
|
-
return [] if once == twice
|
|
494
|
-
|
|
495
|
-
[{ field: "rust_idempotency", once: once, twice: twice }]
|
|
496
|
-
end
|
|
497
|
-
|
|
498
|
-
# ── shared plumbing ─────────────────────────────────────────────
|
|
499
|
-
|
|
500
|
-
# Every [domain, aggregate] pair this sequence actually wrote to —
|
|
501
|
-
# an aggregate with an empty `#entries` never had anything dispatch
|
|
502
|
-
# against it this run, so there is nothing to rehydrate and no
|
|
503
|
-
# finding a "clean, nothing touched" report would mean anything
|
|
504
|
-
# for. Mirrors `Replay#snapshot_instances`' own
|
|
505
|
-
# `bluebooks.each { aggregates.each { repository(...) } }` walk.
|
|
506
|
-
#
|
|
507
|
-
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
|
|
508
|
-
# booted runtime this replay just dispatched through
|
|
509
|
-
# @return [Array<Array(String, Bluebook::Aggregate, Ports::Persistence::AppendOnly,
|
|
510
|
-
# Array<Ports::Persistence::Entry>)>] one `[domain_name, aggregate, repository,
|
|
511
|
-
# entries]` tuple per aggregate with at least one journal entry
|
|
512
|
-
def each_touched_repository(runtime)
|
|
513
|
-
found = []
|
|
514
|
-
runtime.registry.bluebooks.each do |domain_name, bluebook|
|
|
515
|
-
bluebook.aggregates.each do |aggregate|
|
|
516
|
-
repository = runtime.registry.repository(domain_name, aggregate)
|
|
517
|
-
entries = repository.entries
|
|
518
|
-
next if entries.empty?
|
|
519
|
-
|
|
520
|
-
found << [domain_name, aggregate, repository, entries]
|
|
521
|
-
end
|
|
522
|
-
end
|
|
523
|
-
found
|
|
524
|
-
end
|
|
525
|
-
|
|
526
|
-
# Materializes a repository's own stored records, keyed by id, for comparing
|
|
527
|
-
# against a cold read of the same aggregate.
|
|
528
|
-
#
|
|
529
|
-
# @param repository [Ports::Persistence::AppendOnly] the repository to read
|
|
530
|
-
# @return [Hash{String => Object}] every stored record's id (stringified) mapped to
|
|
531
|
-
# its materialized state
|
|
532
|
-
def snapshot(repository)
|
|
533
|
-
repository.all.to_h { |record| [record.id.to_s, Runtime::Value.materialize(record.state)] }
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
# Every [domain, process manager] pair any loaded bluebook declares —
|
|
537
|
-
# regardless of whether this replay's own `history[:saga_instances]`
|
|
538
|
-
# ever touched it (mirrors `each_touched_repository`'s own walk one
|
|
539
|
-
# level up; the "did anything actually persist" filter lives in each
|
|
540
|
-
# check's own caller, same as that method's `entries.empty?` guard).
|
|
541
|
-
# An empty return here is the "domain declares no process manager"
|
|
542
|
-
# skip `check_saga_rehydration`/`check_saga_idempotency` both need —
|
|
543
|
-
# `filter_map`/`flat_map` over an empty Array already answers `[]`,
|
|
544
|
-
# identical to "ran and found nothing," which is deliberate: neither
|
|
545
|
-
# check has a positive "passed" artifact to report either way (see
|
|
546
|
-
# this file's own header on why silence is never a claimed pass).
|
|
547
|
-
#
|
|
548
|
-
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
|
|
549
|
-
# booted runtime this replay just dispatched through
|
|
550
|
-
# @return [Array<Array(String, Bluebook::ProcessManager)>] one `[domain_name,
|
|
551
|
-
# process_manager]` tuple per process manager any loaded bluebook declares
|
|
552
|
-
def each_domain_process_manager(runtime)
|
|
553
|
-
found = []
|
|
554
|
-
runtime.registry.bluebooks.each do |domain_name, bluebook|
|
|
555
|
-
bluebook.process_managers.each { |pm| found << [domain_name, pm] }
|
|
556
|
-
end
|
|
557
|
-
found
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
# The live-side ground truth, key-shape-normalized (see `deep_
|
|
561
|
-
# stringify_keys`'s own comment) so it compares fairly against a
|
|
562
|
-
# real Heki round trip's own shallow-symbolize convention.
|
|
563
|
-
#
|
|
564
|
-
# @param persisted [Hash{String, Symbol => Hash}] `history[:saga_instances]`'s own
|
|
565
|
-
# per-process-manager entry, keyed by correlation, each holding `:state`/`:memory`
|
|
566
|
-
# @return [Hash{String => Hash}] the same rows, keyed by stringified correlation,
|
|
567
|
-
# with `:memory` recursively string-keyed
|
|
568
|
-
def normalize_saga_rows(persisted)
|
|
569
|
-
persisted.each_with_object({}) do |(correlation, saga), rows|
|
|
570
|
-
rows[correlation.to_s] = { state: saga[:state], memory: deep_stringify_keys(saga[:memory]) }
|
|
571
|
-
end
|
|
572
|
-
end
|
|
573
|
-
|
|
574
|
-
# A fresh `Adapters::Heki` at the same `tmp`/`domain` — unmemoized
|
|
575
|
-
# `@store`/`@saga_store`, so `#each_saga` is forced back through
|
|
576
|
-
# `read_snapshot`/`replay_journal`, real bytes off real disk, not
|
|
577
|
-
# whatever the writer that just wrote them still holds in its own
|
|
578
|
-
# process memory (the same reason `fold!`, below, opens a second
|
|
579
|
-
# `Adapters::Heki` instance rather than reading its own writer back).
|
|
580
|
-
#
|
|
581
|
-
# @param anchor [Bluebook::Aggregate] any aggregate of the owning domain, used only
|
|
582
|
-
# to open a `Heki` store at the right domain/root
|
|
583
|
-
# @param tmp [String] the throwaway directory the saga store was written into
|
|
584
|
-
# @param domain_name [String] the owning domain's name, threaded into the store's
|
|
585
|
-
# own `settings`
|
|
586
|
-
# @return [Hash{String => Hash}] every persisted correlation's `:state`/`:memory`,
|
|
587
|
-
# with `:memory` recursively string-keyed
|
|
588
|
-
def cold_read_saga_rows(anchor, tmp, domain_name)
|
|
589
|
-
reader = guarded_heki(aggregate: anchor, root: tmp, settings: { domain: domain_name })
|
|
590
|
-
reader.each_saga.with_object({}) do |(_pm, correlation, state, memory, _completed), rows|
|
|
591
|
-
rows[correlation] = { state: state, memory: deep_stringify_keys(memory) }
|
|
592
|
-
end
|
|
593
|
-
end
|
|
594
|
-
|
|
595
|
-
# `SagaStore#each_saga` only ever symbolizes `memory`'s own top-level
|
|
596
|
-
# keys (`heki/saga_store.rb`'s own `each_saga`, one level deep) —
|
|
597
|
-
# `Registry::SagaPersistence#warn_stalled_saga` already documents
|
|
598
|
-
# this exact asymmetry for the one reserved key production code
|
|
599
|
-
# cares about (`SAGA_PENDING_DISPATCH_KEY`). A nested composite
|
|
600
|
-
# memory field (any saga whose starting event carries a value
|
|
601
|
-
# object, which is most of them — `waybill.bluebook`'s own
|
|
602
|
-
# `ConsignmentRequested` alone has three) comes back with string
|
|
603
|
-
# keys at every level below the top, while `history[:saga_instances]`
|
|
604
|
-
# 's own `Runtime::Value.materialize` call produces symbol keys
|
|
605
|
-
# throughout. That asymmetry is Heki's own documented, accepted
|
|
606
|
-
# storage convention — an "opaque, adapter-agnostic JSON blob"
|
|
607
|
-
# (`SagaInterpreter#checkpoint`'s own comment), never a typed
|
|
608
|
-
# rebuild the way an aggregate's own composite fields get on cold
|
|
609
|
-
# read (this file's own header: there is no VO schema to rebuild
|
|
610
|
-
# against for a saga's memory blob at all) — not a rehydration
|
|
611
|
-
# defect this check exists to find. Recursively re-stringifying
|
|
612
|
-
# both sides before comparing is what tells that known, accepted
|
|
613
|
-
# shape difference apart from an actual data-loss bug (a dropped
|
|
614
|
-
# key, a changed value, a missing field) — exactly the kind (b)'s
|
|
615
|
-
# own seeded fixture in `spec/fuzzing/self_consistency_saga_spec.rb`
|
|
616
|
-
# proves this still catches.
|
|
617
|
-
#
|
|
618
|
-
# @param value [Object] any value; only Hash and Array are recursed into
|
|
619
|
-
# @return [Object] `value` with every Hash key (at every nesting level) replaced by
|
|
620
|
-
# its own `to_s`; anything else is returned unchanged
|
|
621
|
-
def deep_stringify_keys(value)
|
|
622
|
-
case value
|
|
623
|
-
when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
|
|
624
|
-
when Array then value.map { |item| deep_stringify_keys(item) }
|
|
625
|
-
else value
|
|
626
|
-
end
|
|
627
|
-
end
|
|
628
|
-
|
|
629
|
-
# The real, already-announced event this correlation's current
|
|
630
|
-
# checkpoint came from — walked back out of `runtime.registry.
|
|
631
|
-
# saga_log`'s own `advanced: true` rows (newest first), skipping the
|
|
632
|
-
# synthetic `REFUSED` trigger (`Runtime::SagaInterpreter::REFUSED`
|
|
633
|
-
# — a compensating transition's own log entry names that, never a
|
|
634
|
-
# real domain event; there is nothing in `runtime.events` to
|
|
635
|
-
# redeliver for it). `nil` when no real advancing event exists at
|
|
636
|
-
# all (a correlation only ever `begin_saga`'d, never advanced) —
|
|
637
|
-
# `check_saga_idempotency`'s own caller skips a `nil` outright,
|
|
638
|
-
# exactly like `each_touched_repository`'s own "nothing to check"
|
|
639
|
-
# skip one level up.
|
|
640
|
-
#
|
|
641
|
-
# `interpreter.send(:saga_correlation, ...)` — `Correlation` is
|
|
642
|
-
# `private`, and reproducing its own three-tier fallback (a dotted
|
|
643
|
-
# payload field, a stamped passthrough, a self-identifying
|
|
644
|
-
# `event.id`) here rather than reusing it would be exactly the
|
|
645
|
-
# "hand-rolled approximation" this check exists to avoid; `send` on
|
|
646
|
-
# an interpreter sharing this same `runtime`'s own registry is the
|
|
647
|
-
# real thing, not a copy of it.
|
|
648
|
-
#
|
|
649
|
-
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
|
|
650
|
-
# booted runtime this replay just dispatched through
|
|
651
|
-
# @param interpreter [Runtime::SagaInterpreter] the interpreter whose (private)
|
|
652
|
-
# `saga_correlation` re-derives this conversation's own correlation
|
|
653
|
-
# @param process_manager [Bluebook::ProcessManager] the process manager whose saga
|
|
654
|
-
# log is walked
|
|
655
|
-
# @param correlation [Object] the correlation identifier to match, as
|
|
656
|
-
# `saga_correlation` produces it — typically a String
|
|
657
|
-
# @return [Runtime::Event, nil] the real domain event this correlation's current
|
|
658
|
-
# checkpoint last advanced on, or nil if it was only ever `begin_saga`'d
|
|
659
|
-
def last_advancing_event(runtime, interpreter, process_manager, correlation)
|
|
660
|
-
entry = runtime.registry.saga_log.reverse_each.find do |row|
|
|
661
|
-
row[:process_manager] == process_manager.name && row[:instance] == correlation &&
|
|
662
|
-
row[:advanced] && row[:on] != Runtime::SagaInterpreter::REFUSED
|
|
663
|
-
end
|
|
664
|
-
return nil unless entry
|
|
665
|
-
|
|
666
|
-
runtime.events.reverse_each.find do |event|
|
|
667
|
-
event.name == entry[:on] && interpreter.send(:saga_correlation, process_manager, event) == correlation
|
|
668
|
-
end
|
|
669
|
-
end
|
|
670
|
-
|
|
671
|
-
# One (process manager, correlation)'s own redelivery check — pulled
|
|
672
|
-
# out of `check_saga_idempotency` itself so that method's own
|
|
673
|
-
# `flat_map`/`filter_map` walk stays readable; every local this
|
|
674
|
-
# shares with its caller (`interpreter`, `anchor`) is passed in
|
|
675
|
-
# rather than re-derived. The `saga_log` mark/restore pair (BUG#39
|
|
676
|
-
# fix) belongs right where it guards `interpreter.advance`, not in
|
|
677
|
-
# a helper a reader would have to jump to just to see what is and
|
|
678
|
-
# isn't being restored around that one call.
|
|
679
|
-
#
|
|
680
|
-
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the still-live
|
|
681
|
-
# booted runtime this replay just dispatched through
|
|
682
|
-
# @param interpreter [Runtime::SagaInterpreter] the interpreter to redeliver through
|
|
683
|
-
# @param domain_name [String] the owning domain's name
|
|
684
|
-
# @param process_manager [Bluebook::ProcessManager] the process manager under test
|
|
685
|
-
# @param anchor [Bluebook::Aggregate] any aggregate of the owning domain, used only
|
|
686
|
-
# to open a `Heki` store at the right domain/root
|
|
687
|
-
# @param correlation [Object] the conversation's own correlation identifier
|
|
688
|
-
# @param saga [Hash] this correlation's persisted `:state`/`:memory`
|
|
689
|
-
# @param redelivery [Runtime::Event] the real domain event to redeliver
|
|
690
|
-
# @return [Hash, nil] a divergence Hash (`:field`, `:domain`, `:process_manager`,
|
|
691
|
-
# `:correlation`, `:on`, `:before`, `:after`) if the redelivery visibly changed
|
|
692
|
-
# state/memory; nil if it left the checkpoint unchanged, or if the cold-read
|
|
693
|
-
# checkpoint could not be found at all
|
|
694
|
-
def check_one_saga_redelivery(runtime, interpreter, domain_name, process_manager, anchor,
|
|
695
|
-
correlation, saga, redelivery)
|
|
696
|
-
# rubocop:disable-next Metrics/BlockLength
|
|
697
|
-
Dir.mktmpdir("hecks-self-consistency-saga") do |tmp|
|
|
698
|
-
writer = guarded_heki(aggregate: anchor, root: tmp, settings: { domain: domain_name })
|
|
699
|
-
writer.save_saga(process_manager: process_manager.name, correlation: correlation.to_s,
|
|
700
|
-
state: saga[:state], memory: saga[:memory], completed_compensations: [])
|
|
701
|
-
|
|
702
|
-
rehydrated = guarded_heki(aggregate: anchor, root: tmp, settings: { domain: domain_name })
|
|
703
|
-
.each_saga.find { |_pm, corr, *| corr == correlation.to_s }
|
|
704
|
-
next unless rehydrated
|
|
705
|
-
|
|
706
|
-
_pm, _corr, state, memory, compensations = rehydrated
|
|
707
|
-
before = { state: state, memory: deep_stringify_keys(memory) }
|
|
708
|
-
|
|
709
|
-
saga_instances = runtime.registry.saga_instances[process_manager.name]
|
|
710
|
-
original = saga_instances[correlation]
|
|
711
|
-
|
|
712
|
-
# `saga_log` is `runtime.sagas`/`history[:sagas]` itself, by
|
|
713
|
-
# reference (`Dispatcher#sagas` — `@registry.saga_log`).
|
|
714
|
-
# `interpreter.advance` below is a real `advance_saga` dispatch,
|
|
715
|
-
# which appends its own row to this same array unconditionally —
|
|
716
|
-
# a true fact about this probe, but an unrestored append leaks
|
|
717
|
-
# into the primary trace (BUG#39). Marked here, sliced back off
|
|
718
|
-
# in `ensure`, same restore-what-I-mutated idiom as
|
|
719
|
-
# `saga_instances[correlation]` below.
|
|
720
|
-
saga_log = runtime.registry.saga_log
|
|
721
|
-
saga_log_mark = saga_log.size
|
|
722
|
-
begin
|
|
723
|
-
saga_instances[correlation] = { state: state, memory: memory, completed_compensations: compensations || [] }
|
|
724
|
-
interpreter.advance(redelivery, domain_name, only: process_manager)
|
|
725
|
-
|
|
726
|
-
after = saga_instances[correlation]
|
|
727
|
-
after_shape = after && { state: after[:state], memory: deep_stringify_keys(after[:memory]) }
|
|
728
|
-
next if after_shape == before
|
|
729
|
-
|
|
730
|
-
{ field: "saga_redelivery_idempotency", domain: domain_name, process_manager: process_manager.name,
|
|
731
|
-
correlation: correlation, on: redelivery.name, before: before, after: after_shape }
|
|
732
|
-
ensure
|
|
733
|
-
saga_log.slice!(saga_log_mark..) if saga_log.size > saga_log_mark
|
|
734
|
-
|
|
735
|
-
if original
|
|
736
|
-
saga_instances[correlation] = original
|
|
737
|
-
else
|
|
738
|
-
saga_instances.delete(correlation)
|
|
739
|
-
end
|
|
740
|
-
end
|
|
741
|
-
end
|
|
742
|
-
end
|
|
743
|
-
|
|
744
|
-
# One fold of `entries` into `writer` (a real, already-open `Heki`
|
|
745
|
-
# adapter at `tmp`), then a cold read back through a brand new `Heki`
|
|
746
|
-
# instance at the same path — `@store` on a fresh instance starts
|
|
747
|
-
# unmemoized, so `#all` below is forced through `#read` →
|
|
748
|
-
# `#read_snapshot`/`#replay_journal`, real disk bytes, not whatever
|
|
749
|
-
# `writer` still holds cached in its own process memory. Called
|
|
750
|
-
# twice in a row against the same `writer` (see `check`/
|
|
751
|
-
# `check_idempotency` above) is exactly "replay the same journal a
|
|
752
|
-
# second time" — `writer` already holds everything the first fold
|
|
753
|
-
# wrote, so a second fold re-applies the identical operations on
|
|
754
|
-
# top, and the two cold reads either agree (idempotent) or don't.
|
|
755
|
-
#
|
|
756
|
-
# @param writer [Adapters::Heki] the open store to append `entries` into
|
|
757
|
-
# @param tmp [String] the throwaway directory `writer` was opened against
|
|
758
|
-
# @param aggregate [Bluebook::Aggregate] the aggregate `entries` belongs to
|
|
759
|
-
# @param entries [Array<Ports::Persistence::Entry>] the journal entries to fold in
|
|
760
|
-
# @return [Hash{String => Object}] every stored record's id (stringified) mapped to
|
|
761
|
-
# its materialized state, read back cold
|
|
762
|
-
def fold!(writer, tmp, aggregate, entries)
|
|
763
|
-
entries.each do |entry|
|
|
764
|
-
writer.append(entry)
|
|
765
|
-
writer.project(entry)
|
|
766
|
-
end
|
|
767
|
-
|
|
768
|
-
guarded_heki(aggregate: aggregate, root: tmp).all
|
|
769
|
-
.to_h do |record|
|
|
770
|
-
[
|
|
771
|
-
record.id.to_s, Runtime::Value.materialize(record.state)
|
|
772
|
-
]
|
|
773
|
-
end
|
|
774
|
-
end
|
|
775
|
-
|
|
776
|
-
# Recurses through a `Value`'s own fields via `#[]`, not `#to_h` —
|
|
777
|
-
# `#to_h` already materializes every field (`Value.materialize`),
|
|
778
|
-
# which would hide a nested `Value` from this walk before it ever
|
|
779
|
-
# got here. In practice a value object's own composite fields
|
|
780
|
-
# (`Coercion#normalize_composite_fields`) are validated but stored
|
|
781
|
-
# as plain, already-materialized Hashes, not re-wrapped `Value`
|
|
782
|
-
# instances — confirmed live, not assumed — so this recursion finds
|
|
783
|
-
# nothing further past the field it started from for today's
|
|
784
|
-
# coercion pipeline specifically. Kept anyway, not dead code: the
|
|
785
|
-
# generic `Hash`/`Array` branches below reach the exact same nested
|
|
786
|
-
# data through `node[attribute.name]` regardless, and a future
|
|
787
|
-
# change that does start wrapping composite fields as real `Value`
|
|
788
|
-
# instances would be walked correctly here with no change needed.
|
|
789
|
-
# `seen` is a `compare_by_identity` Hash: the same instance can legitimately
|
|
790
|
-
# appear more than once (an aggregate's live state and an event
|
|
791
|
-
# payload both reference the exact same frozen object), and
|
|
792
|
-
# checking it twice would just waste time, never change the
|
|
793
|
-
# answer — identity, not `Value#==`, is the right notion of
|
|
794
|
-
# "already found" here (two different value objects that happen to
|
|
795
|
-
# hold equal fields are still two separate round trips to prove).
|
|
796
|
-
#
|
|
797
|
-
# @param node [Object] the value to walk; a `Runtime::Value` is recorded and
|
|
798
|
-
# recursed into by field, a Hash or Array is recursed into by value/element,
|
|
799
|
-
# anything else is ignored
|
|
800
|
-
# @param found [Array<Array(Runtime::Value, Bluebook::Aggregate, nil)>] accumulator
|
|
801
|
-
# this method appends `[value, aggregate]` pairs to, in place
|
|
802
|
-
# @param seen [Hash] a `compare_by_identity` Hash used as a found-by-identity set,
|
|
803
|
-
# mutated in place
|
|
804
|
-
# @param aggregate [Bluebook::Aggregate, nil] the aggregate `node` belongs to, carried
|
|
805
|
-
# along unchanged for every value found beneath it
|
|
806
|
-
# @return [void]
|
|
807
|
-
def walk_value_objects(node, found, seen, aggregate)
|
|
808
|
-
case node
|
|
809
|
-
when Runtime::Value
|
|
810
|
-
return if seen[node]
|
|
811
|
-
|
|
812
|
-
seen[node] = true
|
|
813
|
-
found << [node, aggregate]
|
|
814
|
-
node.value_object.attributes.each do |attribute|
|
|
815
|
-
walk_value_objects(node[attribute.name], found, seen, aggregate)
|
|
816
|
-
end
|
|
817
|
-
when Hash
|
|
818
|
-
node.each_value { |value| walk_value_objects(value, found, seen, aggregate) }
|
|
819
|
-
when Array
|
|
820
|
-
node.each { |value| walk_value_objects(value, found, seen, aggregate) }
|
|
821
|
-
end
|
|
822
|
-
end
|
|
823
|
-
end
|
|
824
|
-
end
|
|
825
|
-
end
|