hecks 0.3.0 → 1.0.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/lib/hecks/adapters/driven/d1.rb +38 -14
- data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
- data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
- data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
- data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
- data/lib/hecks/adapters/driven/heki.rb +40 -9
- data/lib/hecks/adapters/driven/lambda.rb +16 -2
- data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
- data/lib/hecks/adapters/driven/postgres.rb +76 -29
- data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
- data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
- data/lib/hecks/adapters/driven/sqlite.rb +30 -9
- data/lib/hecks/adapters/driven.rb +19 -1
- data/lib/hecks/behaviors/dsl.rb +29 -0
- data/lib/hecks/behaviors/expectations.rb +62 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
- data/lib/hecks/bluebook/assembly/marks.rb +4 -3
- data/lib/hecks/bluebook/assembly.rb +14 -1
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
- data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
- data/lib/hecks/bluebook/chapter.rb +21 -11
- data/lib/hecks/bluebook/command.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
- data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
- data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
- data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
- data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
- data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
- data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
- data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
- data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
- data/lib/hecks/bluebook/expression/projection.json +48 -0
- data/lib/hecks/bluebook/expression/resolver.rb +161 -10
- data/lib/hecks/bluebook/hexagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
- data/lib/hecks/bluebook/meta_validator.rb +103 -13
- data/lib/hecks/bluebook/model_check.rb +132 -5
- data/lib/hecks/bluebook/pattern_subset.rb +66 -2
- data/lib/hecks/bluebook/process_manager.rb +53 -11
- data/lib/hecks/bluebook/value_object.rb +9 -1
- data/lib/hecks/doc/reference.rb +22 -1
- data/lib/hecks/facade/cli_door.rb +6 -3
- data/lib/hecks/facade/json_door.rb +16 -4
- data/lib/hecks/forms/app.rb +47 -6
- data/lib/hecks/forms/command_form_renderer.rb +1 -1
- data/lib/hecks/forms/field_renderer.rb +11 -4
- data/lib/hecks/forms/html.rb +31 -0
- data/lib/hecks/forms/params.rb +30 -1
- data/lib/hecks/forms/port_argument.rb +46 -0
- data/lib/hecks/forms/record_renderer.rb +6 -2
- data/lib/hecks/forms/record_table.rb +6 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
- data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
- data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
- data/lib/hecks/fuzzing/properties.rb +52 -6
- data/lib/hecks/fuzzing/replay.rb +51 -18
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
- data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
- data/lib/hecks/fuzzing/value_generator.rb +25 -2
- data/lib/hecks/grammar/evolve.rb +33 -0
- data/lib/hecks/grammar/expression.bluebook +8 -5
- data/lib/hecks/grammar/expression_operators.json +497 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
- data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
- data/lib/hecks/language/bluebook/command.bluebook +42 -8
- data/lib/hecks/language/bluebook/entity.bluebook +86 -10
- data/lib/hecks/language/bluebook/policy.bluebook +21 -4
- data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
- data/lib/hecks/language/bluebook/projection.bluebook +6 -6
- data/lib/hecks/language/bluebook/query.bluebook +4 -4
- data/lib/hecks/language/bluebook/shape.bluebook +6 -6
- data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
- data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
- data/lib/hecks/language/oidc.json +20 -0
- data/lib/hecks/language/port.bluebook +30 -2
- data/lib/hecks/naming.rb +54 -1
- data/lib/hecks/ports/access_control.port +7 -2
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.port +6 -2
- data/lib/hecks/ports/agent.rb +1 -1
- data/lib/hecks/ports/authentication.port +4 -2
- data/lib/hecks/ports/authentication.rb +1 -1
- data/lib/hecks/ports/authorization.port +5 -2
- data/lib/hecks/ports/authorization.rb +14 -11
- data/lib/hecks/ports/clock.port +3 -2
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.port +3 -2
- data/lib/hecks/ports/extraction.rb +1 -1
- data/lib/hecks/ports/identity_assignment.port +3 -2
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.port +3 -2
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.port +3 -2
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/persistence/append_only.rb +40 -4
- data/lib/hecks/ports/persistence/execution.rb +6 -1
- data/lib/hecks/ports/persistence/plugin.rb +54 -0
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
- data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
- data/lib/hecks/ports/persistence.rb +1 -1
- data/lib/hecks/ports/projection.rb +20 -0
- data/lib/hecks/projections/diagrams.rb +230 -1
- data/lib/hecks/projections/shape.rb +15 -2
- data/lib/hecks/projector/cli_projector.rb +10 -1
- data/lib/hecks/projector/exporter.rb +19 -2
- data/lib/hecks/query_ir.rb +19 -0
- data/lib/hecks/query_specification/common/null_policy.rb +13 -1
- data/lib/hecks/query_specification/field_path.rb +20 -2
- data/lib/hecks/query_specification/hop_path.rb +7 -5
- data/lib/hecks/runtime/aggregate_lock.rb +45 -0
- data/lib/hecks/runtime/boot_gates.rb +41 -0
- data/lib/hecks/runtime/caller.rb +19 -3
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
- data/lib/hecks/runtime/command_interpreter.rb +97 -13
- data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
- data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
- data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
- data/lib/hecks/runtime/command_rules/references.rb +27 -19
- data/lib/hecks/runtime/dependency_planning.rb +14 -0
- data/lib/hecks/runtime/dispatcher.rb +19 -4
- data/lib/hecks/runtime/entity_interpreter.rb +85 -14
- data/lib/hecks/runtime/errors.rb +22 -0
- data/lib/hecks/runtime/identity.rb +30 -2
- data/lib/hecks/runtime/instance.rb +59 -4
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/loader.rb +59 -18
- data/lib/hecks/runtime/query_interpreter.rb +36 -4
- data/lib/hecks/runtime/reaction_invocation.rb +9 -1
- data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
- data/lib/hecks/runtime/refusal_wording.rb +2 -0
- data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
- data/lib/hecks/runtime/registry/verification.rb +88 -0
- data/lib/hecks/runtime/registry.rb +69 -8
- data/lib/hecks/runtime/saga_interpreter.rb +215 -13
- data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
- data/lib/hecks/runtime/value/admission.rb +19 -1
- data/lib/hecks/runtime/value/coercion.rb +75 -10
- data/lib/hecks/runtime.rb +17 -5
- data/lib/hecks/storehouse.rb +632 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +7 -2
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
- metadata +48 -37
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
- /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "../saga_pending_dispatch"
|
|
2
|
+
|
|
1
3
|
module Hecks
|
|
2
4
|
module Runtime
|
|
3
5
|
class Registry
|
|
@@ -20,8 +22,27 @@ module Hecks
|
|
|
20
22
|
# shares one adapter), and is the honest, documented fallback
|
|
21
23
|
# for the rarer domain genuinely split across more than one
|
|
22
24
|
# local adapter with no default declared.
|
|
25
|
+
#
|
|
26
|
+
# GENUINELY LAZY, GENUINELY POST-BOOT — unlike `rehydrate_sagas!`
|
|
27
|
+
# below (boot-only), this is called from live dispatch
|
|
28
|
+
# (`SagaInterpreter#checkpoint`/`#end_saga`, on every saga
|
|
29
|
+
# transition), so the FIRST call for a given domain can come from
|
|
30
|
+
# any dispatching thread, not just the boot thread. `@saga_persistence`
|
|
31
|
+
# itself is a plain Hash stood up once in `Registry#initialize` (no
|
|
32
|
+
# race on the container), but `resolve_saga_persistence` is real work
|
|
33
|
+
# (a `BindingPolicy.resolve` plus a lazy `repository` build) whose
|
|
34
|
+
# RESULT — the actual adapter instance a domain's sagas persist
|
|
35
|
+
# through — must be the SAME object for every caller: two threads
|
|
36
|
+
# racing the first lookup and each building their own adapter would
|
|
37
|
+
# silently split one domain's saga writes across two adapter
|
|
38
|
+
# instances (worse than `Dispatcher#reaction_depth`'s M20 — that bug
|
|
39
|
+
# corrupted a counter; this one can corrupt WHICH STORE a saga's
|
|
40
|
+
# state lands in). Double-checked locking against a DEDICATED mutex
|
|
41
|
+
# — never `@saga_mutex` — see `Registry#initialize`'s own comment for
|
|
42
|
+
# why reusing that one would deadlock.
|
|
23
43
|
def saga_persistence(domain)
|
|
24
|
-
|
|
44
|
+
key = domain.to_s
|
|
45
|
+
@saga_persistence[key] || @saga_persistence_mutex.synchronize { @saga_persistence[key] ||= resolve_saga_persistence(key) }
|
|
25
46
|
end
|
|
26
47
|
|
|
27
48
|
# WALKS EVERY LOADED DOMAIN, repopulating `saga_instances` from
|
|
@@ -34,10 +55,23 @@ module Hecks
|
|
|
34
55
|
# boot (`Loader.boot`, between `verify!` and dispatcher
|
|
35
56
|
# construction) — a process that's been running has no reason to
|
|
36
57
|
# re-walk its own already-current `saga_instances`.
|
|
58
|
+
#
|
|
59
|
+
# BOOT-TIME-ONLY (OR ITS TEST-RUNNER EQUIVALENT) — this method's
|
|
60
|
+
# only callers are `Loader.run_boot_gates!` (single-threaded, before
|
|
61
|
+
# `dispatcher_for` ever exists) and `Registry#reset_runtime_state!`
|
|
62
|
+
# (single-threaded test runner — see that method's own comment).
|
|
63
|
+
# Never called from live dispatch, so `@saga_instances` mutation
|
|
64
|
+
# here has no concurrent caller to race, unlike its OTHER two write
|
|
65
|
+
# points inside `@saga_mutex.synchronize` blocks (`saga_interpreter.
|
|
66
|
+
# rb`), which genuinely do and are guarded accordingly.
|
|
67
|
+
# rubocop:disable-next Hecks/ThreadSharedIvarMutation
|
|
37
68
|
def rehydrate_sagas!
|
|
38
69
|
@hecksagons.each_key do |domain|
|
|
39
|
-
saga_persistence(domain).each_saga do |process_manager, correlation, state, memory|
|
|
40
|
-
|
|
70
|
+
saga_persistence(domain).each_saga do |process_manager, correlation, state, memory, completed_compensations = []|
|
|
71
|
+
pending = memory.delete(SAGA_PENDING_DISPATCH_KEY)
|
|
72
|
+
@saga_instances[process_manager][correlation] =
|
|
73
|
+
{ state: state, memory: memory, completed_compensations: completed_compensations || [] }
|
|
74
|
+
warn_stalled_saga(domain, process_manager, correlation, state, pending) if pending
|
|
41
75
|
end
|
|
42
76
|
end
|
|
43
77
|
self
|
|
@@ -45,6 +79,44 @@ module Hecks
|
|
|
45
79
|
|
|
46
80
|
private
|
|
47
81
|
|
|
82
|
+
# THE OTHER HALF OF THE OUTBOX-SHAPED FIX (see saga_pending_
|
|
83
|
+
# dispatch.rb) — `SAGA_PENDING_DISPATCH_KEY`, if the crashed
|
|
84
|
+
# process left it standing, means the row's own `state` was
|
|
85
|
+
# checkpointed but the dispatch cascade that justifies it may
|
|
86
|
+
# never have run. Stripped out of `memory` before it becomes
|
|
87
|
+
# this instance's LIVE `:memory` (so nothing downstream — a
|
|
88
|
+
# `given`, a `with:` mapping, the fuzzer — ever sees it), and
|
|
89
|
+
# surfaced loudly instead: a WARNING plus a `saga_log` entry,
|
|
90
|
+
# never an automatic redrive (see saga_pending_dispatch.rb for
|
|
91
|
+
# why redriving without idempotent delivery would be worse than
|
|
92
|
+
# the stall). This is real, durable crash-recovery VISIBILITY —
|
|
93
|
+
# still not the reconciliation itself, which stays a human's
|
|
94
|
+
# call until hecks has idempotent redelivery to make it safe.
|
|
95
|
+
# rubocop:disable-next Hecks/ThreadSharedIvarMutation -- same
|
|
96
|
+
# justification as `rehydrate_sagas!`'s own disable comment
|
|
97
|
+
# above: this method's ONLY caller is that boot-time-only walk,
|
|
98
|
+
# never live dispatch, so `@saga_log` has no concurrent writer
|
|
99
|
+
# to race here.
|
|
100
|
+
def warn_stalled_saga(domain, process_manager, correlation, state, pending)
|
|
101
|
+
# `.transform_keys(&:to_sym)` — Heki's own `each_saga` only
|
|
102
|
+
# symbolizes `memory`'s TOP-level keys (`SagaStore#each_saga`'s
|
|
103
|
+
# own `transform_keys`, one level deep); a value NESTED under
|
|
104
|
+
# one of those keys, like this marker, comes back with plain
|
|
105
|
+
# string keys from Heki specifically, symbol keys already from
|
|
106
|
+
# Postgres/SQLite/D1's own `symbolize_names: true` parse. Normalizing
|
|
107
|
+
# here, once, is simpler than teaching every adapter's own
|
|
108
|
+
# shallow/deep parsing convention about this one reserved key.
|
|
109
|
+
pending = pending.transform_keys(&:to_sym)
|
|
110
|
+
dispatches = Array(pending[:dispatches]).join(", ")
|
|
111
|
+
warn "[hecks] #{domain} rehydrated #{process_manager} instance #{correlation.inspect} in state " \
|
|
112
|
+
"#{state.inspect} with a dispatch left pending from before the last crash/restart — " \
|
|
113
|
+
"#{dispatches} (on #{pending[:on].inspect}, #{pending[:from].inspect} -> #{pending[:to].inspect}) " \
|
|
114
|
+
"may or may not have actually run. hecks does not auto-redrive a pending saga dispatch (that " \
|
|
115
|
+
"needs idempotent delivery, which this pipeline doesn't have yet); reconcile this instance by hand."
|
|
116
|
+
@saga_log << { process_manager: process_manager, instance: correlation, rehydrated_stalled: true,
|
|
117
|
+
state: state, pending: pending }
|
|
118
|
+
end
|
|
119
|
+
|
|
48
120
|
def resolve_saga_persistence(domain)
|
|
49
121
|
anchor = hecksagon(domain) && bluebook(domain)&.aggregates&.first
|
|
50
122
|
return Ports::Persistence::NULL_SAGA_STORE unless anchor
|
|
@@ -12,6 +12,7 @@ module Hecks
|
|
|
12
12
|
module Verification
|
|
13
13
|
def verify!
|
|
14
14
|
verify_default_adapter!
|
|
15
|
+
verify_singleton_port_answers!
|
|
15
16
|
|
|
16
17
|
@hecksagons.each_value do |hexagon|
|
|
17
18
|
refuse_ungoverned_roles!(hexagon)
|
|
@@ -42,6 +43,8 @@ module Hecks
|
|
|
42
43
|
|
|
43
44
|
repository(hexagon.domain, aggregate)
|
|
44
45
|
end
|
|
46
|
+
|
|
47
|
+
warn_undurable_sagas!(hexagon)
|
|
45
48
|
end
|
|
46
49
|
self
|
|
47
50
|
end
|
|
@@ -66,6 +69,7 @@ module Hecks
|
|
|
66
69
|
|
|
67
70
|
def check_verb(bind)
|
|
68
71
|
port = port_for(bind)
|
|
72
|
+
check_answers(port, bind.adapter)
|
|
69
73
|
return if port.verb.to_s == bind.verb.to_s
|
|
70
74
|
|
|
71
75
|
raise WiringError,
|
|
@@ -73,6 +77,55 @@ module Hecks
|
|
|
73
77
|
"and cannot satisfy #{bind.verb}"
|
|
74
78
|
end
|
|
75
79
|
|
|
80
|
+
# THE METHOD CONTRACT A `.port` FILE'S `verb`/`signal` NEVER
|
|
81
|
+
# CARRIED — an adapter can name the right port, satisfy the right
|
|
82
|
+
# verb, and admit every `.world` setting `check_settings` checks,
|
|
83
|
+
# and still be missing the one method a live dispatch will
|
|
84
|
+
# actually call. `answers` is optional per port (an empty list is
|
|
85
|
+
# today's pre-existing behavior, unchecked), so this only ever
|
|
86
|
+
# tightens a port that opted in.
|
|
87
|
+
def check_answers(port, adapter_name)
|
|
88
|
+
answers = Array(port.answers)
|
|
89
|
+
return if answers.empty?
|
|
90
|
+
|
|
91
|
+
klass = adapter_class(adapter_name)
|
|
92
|
+
missing = answers.reject { |method_name| klass.respond_to?(method_name) }
|
|
93
|
+
return if missing.empty?
|
|
94
|
+
|
|
95
|
+
raise WiringError,
|
|
96
|
+
"#{adapter_name} declares the #{port.name} port but does not respond to " \
|
|
97
|
+
"#{missing.map(&:inspect).join(', ')} — #{port.name}.port declares answers " \
|
|
98
|
+
"#{answers.map(&:inspect).join(', ')}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# THE NINE SINGLETON PORTS' OWN GAP — `persistence`, `projection`
|
|
102
|
+
# and `loading` are per-aggregate bindings, checked above through
|
|
103
|
+
# every real `bind` a hexagon declares; a singleton port
|
|
104
|
+
# (`clock`, `authorization`, …) is never bound to an aggregate at
|
|
105
|
+
# all, so nothing above ever resolves one and nothing above ever
|
|
106
|
+
# ran `check_answers` against it. Each one's own `Ports::*.adapter`
|
|
107
|
+
# already refuses zero or multiple implementations, live, at
|
|
108
|
+
# first dispatch — that stays exactly as-is here (0 or 2+ is
|
|
109
|
+
# ambiguity, not a method-contract question, and asserting every
|
|
110
|
+
# declared port MUST have exactly one adapter would wrongly
|
|
111
|
+
# refuse a boot that simply never wires a port it doesn't use).
|
|
112
|
+
# This only ever tightens the ONE case those checks don't cover:
|
|
113
|
+
# exactly one adapter, wired, missing a method `answers` names.
|
|
114
|
+
PER_AGGREGATE_PORTS = %w[persistence projection loading].freeze
|
|
115
|
+
|
|
116
|
+
def verify_singleton_port_answers!
|
|
117
|
+
@ports.each_value do |port|
|
|
118
|
+
next if PER_AGGREGATE_PORTS.include?(port.name)
|
|
119
|
+
next if Array(port.answers).empty?
|
|
120
|
+
|
|
121
|
+
implementations = @adapters.values.select { |a| a.port == port.name }
|
|
122
|
+
next unless implementations.size == 1
|
|
123
|
+
|
|
124
|
+
check_answers(port, implementations.first.name)
|
|
125
|
+
end
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
|
|
76
129
|
def check_settings(bind, settings)
|
|
77
130
|
adapter = @adapters[bind.adapter]
|
|
78
131
|
return unless adapter
|
|
@@ -152,6 +205,41 @@ module Hecks
|
|
|
152
205
|
def commands_in(bluebook_ir)
|
|
153
206
|
bluebook_ir.aggregates.flat_map { |aggregate| aggregate.commands + aggregate.entities.flat_map(&:commands) }
|
|
154
207
|
end
|
|
208
|
+
|
|
209
|
+
# A domain that declares a `process_manager` but whose
|
|
210
|
+
# `saga_persistence` resolves to `NULL_SAGA_STORE` (no anchor
|
|
211
|
+
# aggregate, a RemoteRuntime-shaped adapter, an adapter that
|
|
212
|
+
# doesn't `respond_to?(:save_saga)`, or a rescued WiringError —
|
|
213
|
+
# see `SagaPersistence#resolve_saga_persistence`) gets sagas that
|
|
214
|
+
# advance correctly in-process and vanish on restart: no
|
|
215
|
+
# checkpoint written, nothing for `rehydrate_sagas!` to find, no
|
|
216
|
+
# compensation ever replayed. That is silent right up until the
|
|
217
|
+
# process actually dies mid-saga — the same "consistency/
|
|
218
|
+
# freshness defect applied to access control, failing open" ADR
|
|
219
|
+
# 0025 named for an unchecked `role`, here applied to saga
|
|
220
|
+
# durability instead.
|
|
221
|
+
#
|
|
222
|
+
# A WARNING, NOT A REFUSAL — unlike `refuse_ungoverned_roles!`,
|
|
223
|
+
# running sagas on a store with no `save_saga` is legitimate on
|
|
224
|
+
# purpose in a fast in-memory test/dev boot (this project's own
|
|
225
|
+
# `saga_durability_spec.rb` boots a process manager on `Memory`
|
|
226
|
+
# specifically to exercise the saga_mutex without real I/O), so
|
|
227
|
+
# refusing the boot outright would break a choice an author made
|
|
228
|
+
# deliberately. What a deploy needs is for the gap to be loud and
|
|
229
|
+
# undeniable, not for local dev/test to become impossible.
|
|
230
|
+
def warn_undurable_sagas!(hexagon)
|
|
231
|
+
bluebook_ir = bluebook(hexagon.domain)
|
|
232
|
+
return unless bluebook_ir
|
|
233
|
+
return if bluebook_ir.process_managers.empty?
|
|
234
|
+
return unless saga_persistence(hexagon.domain).equal?(Ports::Persistence::NULL_SAGA_STORE)
|
|
235
|
+
|
|
236
|
+
names = bluebook_ir.process_managers.map(&:name).join(", ")
|
|
237
|
+
warn "[hecks] #{hexagon.domain} declares process_manager(s) #{names} but its resolved " \
|
|
238
|
+
"persistence adapter has no save_saga — saga state advances correctly in-process " \
|
|
239
|
+
"and is LOST on restart (no checkpoint, no rehydration, no compensation replay). " \
|
|
240
|
+
"Bind this domain to an adapter that implements save_saga if this process_manager " \
|
|
241
|
+
"must survive a crash."
|
|
242
|
+
end
|
|
155
243
|
end
|
|
156
244
|
end
|
|
157
245
|
end
|
|
@@ -45,9 +45,11 @@ module Hecks
|
|
|
45
45
|
# GUARDS `saga_instances`' OWN mutation+checkpoint sequence
|
|
46
46
|
# (`SagaInterpreter`'s 4 write points, §7) — the same shape of
|
|
47
47
|
# hazard this codebase's own prior audit already flagged for
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
48
|
+
# `Dispatcher#reenter`'s reaction-depth counter (M20: a
|
|
49
|
+
# thread-shared ivar with no lock, since fixed by moving it to
|
|
50
|
+
# `Thread.current`, dispatcher.rb), made meaningfully easier to
|
|
51
|
+
# hit here once a persistence write sits in the same critical
|
|
52
|
+
# section. Held across the in-memory
|
|
51
53
|
# mutation AND the checkpoint write together, never across a
|
|
52
54
|
# saga's own dispatch cascade — see `SagaInterpreter#advance_saga`'s
|
|
53
55
|
# own comment for why that distinction matters (non-reentrant
|
|
@@ -56,6 +58,35 @@ module Hecks
|
|
|
56
58
|
@repositories = {}
|
|
57
59
|
@projection_repositories = {}
|
|
58
60
|
@bluebook_builders = {}
|
|
61
|
+
# EAGER, NOT LAZY — see `#resolved_eras`'s own comment for why. Built
|
|
62
|
+
# here rather than `@resolved_eras ||= {}` on first access so there is
|
|
63
|
+
# no window, post-boot, where two concurrently dispatching threads
|
|
64
|
+
# could race creating this Hash (Hecks/ThreadSharedIvarMutation; the
|
|
65
|
+
# same shape of hazard `Dispatcher#reenter`'s `@reaction_depth` was
|
|
66
|
+
# fixed for). Every WRITE into it still only ever happens at boot,
|
|
67
|
+
# single-threaded (`EraResolver.check!`, a `:pre_verify` boot gate) —
|
|
68
|
+
# this only removes the race on standing up the container itself for
|
|
69
|
+
# a boot with no era-plugin domain at all, whose first touch would
|
|
70
|
+
# otherwise be a live dispatch's own `RepositoryFactory.build` read.
|
|
71
|
+
@resolved_eras = {}
|
|
72
|
+
# EAGER, NOT LAZY — see `#capability_graph`'s own comment for why.
|
|
73
|
+
# `CapabilityGraph.new` only stores the registry reference; there is
|
|
74
|
+
# no reason to defer it, and doing so removes the exact same
|
|
75
|
+
# first-access race `#resolved_eras` above does, while preserving the
|
|
76
|
+
# "same instance every call" identity `spec/runtime/capability_graph_
|
|
77
|
+
# spec.rb` already requires.
|
|
78
|
+
@capability_graph = CapabilityGraph.new(self)
|
|
79
|
+
# `@saga_persistence` itself is eager (see `#saga_persistence`'s own
|
|
80
|
+
# comment) — only the PER-DOMAIN resolution inside it is genuinely
|
|
81
|
+
# expensive and lazy, guarded by this dedicated mutex. NOT the same
|
|
82
|
+
# mutex as `@saga_mutex`: `checkpoint` (saga_interpreter.rb) calls
|
|
83
|
+
# `saga_persistence(domain)` from INSIDE an `@saga_mutex.synchronize`
|
|
84
|
+
# block, so reusing `@saga_mutex` here would deadlock the very first
|
|
85
|
+
# time a saga advanced (a `Mutex` is not reentrant — the exact
|
|
86
|
+
# warning `@saga_mutex`'s own comment already gives for a different
|
|
87
|
+
# reason).
|
|
88
|
+
@saga_persistence = {}
|
|
89
|
+
@saga_persistence_mutex = Mutex.new
|
|
59
90
|
end
|
|
60
91
|
|
|
61
92
|
# THE BUILDER STAYS OPEN FOR THE LIFE OF THIS REGISTRY, keyed by chapter
|
|
@@ -67,6 +98,20 @@ module Hecks
|
|
|
67
98
|
@bluebook_builders[name.to_s] ||= yield
|
|
68
99
|
end
|
|
69
100
|
|
|
101
|
+
# BOOT-TIME-ONLY, SINGLE-THREADED — every `add_*` below (through
|
|
102
|
+
# `add_translation`) is called exclusively from `Hecks.collect`
|
|
103
|
+
# (hecks.rb), which is what `Hecks.bluebook`/`.hecksagon`/`.port`/
|
|
104
|
+
# `.adapter`/`.world`/`.translation` run inside while a `.bluebook`/
|
|
105
|
+
# `.hecksagon`/`.world` file is being `Kernel.load`ed — i.e. strictly
|
|
106
|
+
# during `Loader.boot`/`.boot_files`, before `dispatcher_for` ever
|
|
107
|
+
# hands this registry to a live, multi-threaded caller. Nothing
|
|
108
|
+
# downstream of boot ever calls these — verified by grepping every
|
|
109
|
+
# call site in lib/ and spec/ before writing this — so unlike
|
|
110
|
+
# `#resolved_eras`/`#capability_graph`/`#saga_persistence` (each
|
|
111
|
+
# reachable from live dispatch, and fixed for real above/in
|
|
112
|
+
# registry/saga_persistence.rb) there is no concurrent caller for
|
|
113
|
+
# `Hecks/ThreadSharedIvarMutation` to actually be warning about here.
|
|
114
|
+
# rubocop:disable Hecks/ThreadSharedIvarMutation
|
|
70
115
|
def add_bluebook(item) = @bluebooks[item.name] = item
|
|
71
116
|
|
|
72
117
|
# MERGED, NOT REPLACED — RECOVERED, not new (see Runtime::Loader
|
|
@@ -99,12 +144,16 @@ module Hecks
|
|
|
99
144
|
end
|
|
100
145
|
|
|
101
146
|
def add_translation(item) = @translations << item
|
|
147
|
+
# rubocop:enable Hecks/ThreadSharedIvarMutation
|
|
102
148
|
|
|
103
149
|
# {domain name => era ordinal} as resolved by the boot-time era
|
|
104
150
|
# gate. A lineage adapter writes into ITS OWN era's partition —
|
|
105
151
|
# which, for an old checkout booting a held-but-superseded shape,
|
|
106
|
-
# is not the newest one.
|
|
107
|
-
|
|
152
|
+
# is not the newest one. The Hash itself is stood up in `initialize`
|
|
153
|
+
# (see that comment) — this is a plain reader, not a memoizer;
|
|
154
|
+
# `Hecks/ThreadSharedIvarMutation` is the reason there is no `||=`
|
|
155
|
+
# left here to flag.
|
|
156
|
+
attr_reader :resolved_eras
|
|
108
157
|
|
|
109
158
|
def bluebook(name) = @bluebooks[name.to_s]
|
|
110
159
|
def hecksagon(name) = @hecksagons[name.to_s]
|
|
@@ -133,6 +182,16 @@ module Hecks
|
|
|
133
182
|
# test to get isolation (`Behaviors::Expectations.run_one`) — ~2s a
|
|
134
183
|
# boot, 76 chess behaviours = two and a half minutes of booting the
|
|
135
184
|
# same two files — can now boot once and reset between tests.
|
|
185
|
+
#
|
|
186
|
+
# SINGLE-THREADED CALLER, THE SAME REASON THE `add_*` CLUSTER ABOVE
|
|
187
|
+
# IS EXEMPT — `Behaviors::Expectations.run_one` is this method's ONLY
|
|
188
|
+
# caller (verified by grep before writing this), and it runs one
|
|
189
|
+
# test at a time: `Runner#run` maps over tests sequentially, and
|
|
190
|
+
# `Behaviors.rspec`'s generated examples run under RSpec's own
|
|
191
|
+
# single-threaded example loop. No production dispatch path calls
|
|
192
|
+
# this at all — a live Puma worker pool never resets a registry out
|
|
193
|
+
# from under itself mid-flight.
|
|
194
|
+
# rubocop:disable-next Hecks/ThreadSharedIvarMutation
|
|
136
195
|
def reset_runtime_state!
|
|
137
196
|
@event_log.clear
|
|
138
197
|
@reaction_log.clear
|
|
@@ -146,9 +205,11 @@ module Hecks
|
|
|
146
205
|
self
|
|
147
206
|
end
|
|
148
207
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
208
|
+
# Built eagerly in `initialize` (see that comment) — this is a plain
|
|
209
|
+
# reader, not a memoizer; `spec/runtime/capability_graph_spec.rb`
|
|
210
|
+
# asserts the SAME instance comes back every call, which this still
|
|
211
|
+
# gives, just without a lazy `||=` race on standing it up.
|
|
212
|
+
attr_reader :capability_graph
|
|
152
213
|
|
|
153
214
|
def read_repository(domain, aggregate)
|
|
154
215
|
key = [domain.to_s, aggregate.hecks_name]
|