phronomy 0.18.0 → 0.20.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/CHANGELOG.md +25 -0
- data/CONTRIBUTING.md +30 -0
- data/README.md +2 -0
- data/docs/decisions/009-state-store-abstraction.md +1 -1
- data/docs/decisions/014-unified-persistence-durable-state.md +273 -0
- data/docs/features.md +27 -1
- data/docs/getting-started.md +37 -1
- data/docs/migrations/0.19.md +154 -0
- data/docs/persistence-backends.md +504 -0
- data/docs/runtime-and-concurrency.md +93 -2
- data/lib/phronomy/agent/agent_execution.rb +29 -0
- data/lib/phronomy/agent/base.rb +81 -36
- data/lib/phronomy/agent/context_assembler.rb +13 -3
- data/lib/phronomy/agent/execution_coordinator.rb +420 -249
- data/lib/phronomy/agent/journal_projection.rb +5 -1
- data/lib/phronomy/agent/llm_call_record.rb +20 -0
- data/lib/phronomy/configuration.rb +2 -1
- data/lib/phronomy/engine/event_loop.rb +86 -8
- data/lib/phronomy/engine/fsm_session.rb +6 -4
- data/lib/phronomy/engine/runtime.rb +7 -0
- data/lib/phronomy/persistence/in_memory.rb +113 -8
- data/lib/phronomy/persistence.rb +109 -6
- data/lib/phronomy/testing/persistence_contract/a_content_store.rb +50 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +164 -0
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +215 -0
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +119 -0
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +99 -0
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +202 -0
- data/lib/phronomy/testing/persistence_contract.rb +41 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +10 -9
- data/lib/phronomy/workflow_runner.rb +361 -95
- data/lib/phronomy.rb +9 -0
- metadata +12 -5
- data/lib/phronomy/state_store/base.rb +0 -48
- data/lib/phronomy/state_store/in_memory.rb +0 -62
- data/scripts/check_private_enforcement.rb +0 -93
|
@@ -10,9 +10,13 @@ module Phronomy
|
|
|
10
10
|
].freeze
|
|
11
11
|
KNOWLEDGE_RESET_KINDS = %i[knowledge_cleared context_reset].freeze
|
|
12
12
|
|
|
13
|
-
def initialize(
|
|
13
|
+
def initialize(agent_root:, persistence: nil, records: nil)
|
|
14
14
|
@persistence = persistence
|
|
15
15
|
@agent_root = agent_root
|
|
16
|
+
@records = records&.dup&.freeze
|
|
17
|
+
if @records.nil? && @persistence.nil?
|
|
18
|
+
raise ArgumentError, "JournalProjection requires persistence: or records:"
|
|
19
|
+
end
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def transcript_records
|
|
@@ -39,6 +39,10 @@ module Phronomy
|
|
|
39
39
|
freeze
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Returns the canonical durable representation of this LLM Call record.
|
|
43
|
+
#
|
|
44
|
+
# @return [Hash{String => Object}]
|
|
45
|
+
# @api public
|
|
42
46
|
def to_h
|
|
43
47
|
ATTRIBUTES.to_h do |name|
|
|
44
48
|
value = public_send(name)
|
|
@@ -46,6 +50,22 @@ module Phronomy
|
|
|
46
50
|
[name.to_s, value]
|
|
47
51
|
end
|
|
48
52
|
end
|
|
53
|
+
|
|
54
|
+
# Restores an LLM Call record from its canonical durable representation.
|
|
55
|
+
# String and Symbol top-level keys are accepted so database adapters may
|
|
56
|
+
# pass either a parsed JSON object or a Ruby-native Hash.
|
|
57
|
+
#
|
|
58
|
+
# @param hash [Hash]
|
|
59
|
+
# @return [LLMCallRecord]
|
|
60
|
+
# @api public
|
|
61
|
+
def self.from_h(hash)
|
|
62
|
+
attributes = ATTRIBUTES.to_h do |name|
|
|
63
|
+
key = hash.key?(name.to_s) ? name.to_s : name
|
|
64
|
+
[name, hash.fetch(key)]
|
|
65
|
+
end
|
|
66
|
+
attributes[:status] = attributes.fetch(:status).to_sym
|
|
67
|
+
new(**attributes)
|
|
68
|
+
end
|
|
49
69
|
end
|
|
50
70
|
end
|
|
51
71
|
end
|
|
@@ -16,7 +16,7 @@ module Phronomy
|
|
|
16
16
|
attr_accessor :trace_pii
|
|
17
17
|
attr_accessor :logger
|
|
18
18
|
attr_accessor :event_loop_stop_grace_seconds
|
|
19
|
-
attr_accessor :
|
|
19
|
+
attr_accessor :persistence
|
|
20
20
|
attr_accessor :tool_result_max_size
|
|
21
21
|
attr_accessor :llm_adapter
|
|
22
22
|
attr_accessor :event_loop_starvation_threshold_seconds
|
|
@@ -53,6 +53,7 @@ module Phronomy
|
|
|
53
53
|
@authorization_pool_size = 4
|
|
54
54
|
@authorization_queue_size = 100
|
|
55
55
|
@authorization_timeout = 5
|
|
56
|
+
@persistence = nil
|
|
56
57
|
end
|
|
57
58
|
end
|
|
58
59
|
end
|
|
@@ -29,6 +29,7 @@ module Phronomy
|
|
|
29
29
|
@fsms = {}
|
|
30
30
|
@waiting = {}
|
|
31
31
|
@admitted_session_ids = Set.new
|
|
32
|
+
@workflow_admissions = {}
|
|
32
33
|
|
|
33
34
|
@lifecycle_mutex = Mutex.new
|
|
34
35
|
@idle_cond = ConditionVariable.new
|
|
@@ -99,7 +100,7 @@ module Phronomy
|
|
|
99
100
|
rescue
|
|
100
101
|
@admitted_session_ids.delete(fsm_session.id)
|
|
101
102
|
@outstanding_sessions -= 1
|
|
102
|
-
@idle_cond.broadcast if
|
|
103
|
+
@idle_cond.broadcast if runtime_idle_locked?
|
|
103
104
|
raise
|
|
104
105
|
end
|
|
105
106
|
end
|
|
@@ -152,6 +153,73 @@ module Phronomy
|
|
|
152
153
|
true
|
|
153
154
|
end
|
|
154
155
|
|
|
156
|
+
# Reserves one logical Workflow thread for one concrete FSMSession execution.
|
|
157
|
+
# thread_id is durable Workflow identity; owner_fsm_session_id is the
|
|
158
|
+
# Runtime-only identity of the invocation/resume that currently owns it.
|
|
159
|
+
def admit_workflow(thread_id, owner_fsm_session_id:)
|
|
160
|
+
key = thread_id.to_s
|
|
161
|
+
owner = owner_fsm_session_id.to_s
|
|
162
|
+
raise ArgumentError, "thread_id must not be empty" if key.empty?
|
|
163
|
+
raise ArgumentError, "owner_fsm_session_id must not be empty" if owner.empty?
|
|
164
|
+
|
|
165
|
+
@lifecycle_mutex.synchronize do
|
|
166
|
+
ensure_accepting_registrations!
|
|
167
|
+
current_owner = @workflow_admissions[key]
|
|
168
|
+
if current_owner
|
|
169
|
+
raise Phronomy::Error,
|
|
170
|
+
"Workflow thread #{key.inspect} is already owned by " \
|
|
171
|
+
"FSMSession #{current_owner.inspect}"
|
|
172
|
+
end
|
|
173
|
+
@workflow_admissions[key] = owner
|
|
174
|
+
end
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Releases a Workflow reservation only when the caller is its current owner.
|
|
179
|
+
# A failed competing admission can therefore never release another session's
|
|
180
|
+
# reservation during cleanup.
|
|
181
|
+
def release_workflow(thread_id, owner_fsm_session_id:)
|
|
182
|
+
key = thread_id.to_s
|
|
183
|
+
owner = owner_fsm_session_id.to_s
|
|
184
|
+
@lifecycle_mutex.synchronize do
|
|
185
|
+
next false unless @workflow_admissions[key] == owner
|
|
186
|
+
|
|
187
|
+
@workflow_admissions.delete(key)
|
|
188
|
+
@idle_cond.broadcast if runtime_idle_locked?
|
|
189
|
+
true
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def workflow_admission_owner(thread_id)
|
|
194
|
+
@lifecycle_mutex.synchronize { @workflow_admissions[thread_id.to_s] }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Resolves durable Workflow identity to the currently owning FSMSession and
|
|
198
|
+
# enqueues the event atomically with that ownership check.
|
|
199
|
+
def post_to_workflow(thread_id:, event:, payload: nil)
|
|
200
|
+
queued_depth = nil
|
|
201
|
+
posted_event = nil
|
|
202
|
+
accepted = @lifecycle_mutex.synchronize do
|
|
203
|
+
next false unless accepting_events?
|
|
204
|
+
|
|
205
|
+
owner = @workflow_admissions[thread_id.to_s]
|
|
206
|
+
next false unless owner
|
|
207
|
+
next false unless @admitted_session_ids.include?(owner)
|
|
208
|
+
|
|
209
|
+
posted_event = Phronomy::Event.new(
|
|
210
|
+
type: event.to_sym,
|
|
211
|
+
target_id: owner,
|
|
212
|
+
payload: payload
|
|
213
|
+
)
|
|
214
|
+
queued_depth = enqueue([posted_event, monotonic_nanoseconds])
|
|
215
|
+
true
|
|
216
|
+
end
|
|
217
|
+
return false unless accepted
|
|
218
|
+
|
|
219
|
+
check_queue_backlog(queued_depth, posted_event)
|
|
220
|
+
true
|
|
221
|
+
end
|
|
222
|
+
|
|
155
223
|
# Interrupts the queue wait so EventLoop can recompute the next timer deadline.
|
|
156
224
|
def wake
|
|
157
225
|
@queue.push(WAKE)
|
|
@@ -180,12 +248,12 @@ module Phronomy
|
|
|
180
248
|
end
|
|
181
249
|
|
|
182
250
|
def idle?
|
|
183
|
-
@lifecycle_mutex.synchronize {
|
|
251
|
+
@lifecycle_mutex.synchronize { runtime_idle_locked? }
|
|
184
252
|
end
|
|
185
253
|
|
|
186
254
|
def wait_until_idle(deadline)
|
|
187
255
|
@lifecycle_mutex.synchronize do
|
|
188
|
-
until
|
|
256
|
+
until runtime_idle_locked?
|
|
189
257
|
remaining = deadline - monotonic_now
|
|
190
258
|
return false if remaining <= 0
|
|
191
259
|
@idle_cond.wait(@lifecycle_mutex, remaining)
|
|
@@ -287,7 +355,9 @@ module Phronomy
|
|
|
287
355
|
session_id = event.payload.fetch(:session_id)
|
|
288
356
|
session = @fsms.delete(session_id)
|
|
289
357
|
waiter = @waiting.delete(session_id)
|
|
290
|
-
# decrement before waking caller so wait_until_idle sees
|
|
358
|
+
# decrement before waking caller so wait_until_idle sees the control-plane
|
|
359
|
+
# session count immediately; Workflow durable admission may intentionally
|
|
360
|
+
# keep Runtime non-idle until its terminal save completes.
|
|
291
361
|
decrement_outstanding if session
|
|
292
362
|
complete_waiter(waiter, event.payload.fetch(:result))
|
|
293
363
|
when :start
|
|
@@ -297,10 +367,11 @@ module Phronomy
|
|
|
297
367
|
@waiting[session.id] = waiter if waiter
|
|
298
368
|
session.start
|
|
299
369
|
when :agent_terminal_ready
|
|
300
|
-
# Existing Agent terminalisation is retained in this proposal. A separate
|
|
301
|
-
# follow-up may move it to an Agent-owned terminalising FSM state.
|
|
302
370
|
cmd = event.payload.fetch(:command)
|
|
303
371
|
cmd.coordinator.deliver_on_event_loop(cmd)
|
|
372
|
+
when :workflow_persistence_ready
|
|
373
|
+
cmd = event.payload.fetch(:command)
|
|
374
|
+
cmd.runner.deliver_persistence_on_event_loop(cmd)
|
|
304
375
|
end
|
|
305
376
|
end
|
|
306
377
|
|
|
@@ -314,7 +385,7 @@ module Phronomy
|
|
|
314
385
|
def begin_stopping_if_idle
|
|
315
386
|
@lifecycle_mutex.synchronize do
|
|
316
387
|
return false unless @state == :draining
|
|
317
|
-
return false unless
|
|
388
|
+
return false unless runtime_idle_locked?
|
|
318
389
|
|
|
319
390
|
@state = :stopping
|
|
320
391
|
@queue.push(STOP)
|
|
@@ -337,6 +408,7 @@ module Phronomy
|
|
|
337
408
|
@fsms.clear
|
|
338
409
|
@lifecycle_mutex.synchronize do
|
|
339
410
|
@admitted_session_ids.clear
|
|
411
|
+
@workflow_admissions.clear
|
|
340
412
|
@outstanding_sessions = 0
|
|
341
413
|
@idle_cond.broadcast
|
|
342
414
|
end
|
|
@@ -356,6 +428,7 @@ module Phronomy
|
|
|
356
428
|
@lifecycle_mutex.synchronize do
|
|
357
429
|
@state = :failed
|
|
358
430
|
@admitted_session_ids.clear
|
|
431
|
+
@workflow_admissions.clear
|
|
359
432
|
@idle_cond.broadcast
|
|
360
433
|
end
|
|
361
434
|
cleanup_abandoned_work(error)
|
|
@@ -375,10 +448,14 @@ module Phronomy
|
|
|
375
448
|
def decrement_outstanding
|
|
376
449
|
@lifecycle_mutex.synchronize do
|
|
377
450
|
@outstanding_sessions -= 1 if @outstanding_sessions.positive?
|
|
378
|
-
@idle_cond.broadcast if
|
|
451
|
+
@idle_cond.broadcast if runtime_idle_locked?
|
|
379
452
|
end
|
|
380
453
|
end
|
|
381
454
|
|
|
455
|
+
def runtime_idle_locked?
|
|
456
|
+
@outstanding_sessions.zero? && @workflow_admissions.empty?
|
|
457
|
+
end
|
|
458
|
+
|
|
382
459
|
def join_until(deadline)
|
|
383
460
|
remaining = deadline - monotonic_now
|
|
384
461
|
return if remaining <= 0
|
|
@@ -391,6 +468,7 @@ module Phronomy
|
|
|
391
468
|
@lifecycle_mutex.synchronize do
|
|
392
469
|
@state = :terminated
|
|
393
470
|
@admitted_session_ids.clear
|
|
471
|
+
@workflow_admissions.clear
|
|
394
472
|
@thread = nil unless @thread&.alive?
|
|
395
473
|
@idle_cond.broadcast
|
|
396
474
|
end
|
|
@@ -25,9 +25,11 @@ module Phronomy
|
|
|
25
25
|
event_loop:,
|
|
26
26
|
resume_event: nil,
|
|
27
27
|
resume_phase: nil,
|
|
28
|
-
stable_observer: nil
|
|
28
|
+
stable_observer: nil,
|
|
29
|
+
graph_thread_id: nil
|
|
29
30
|
)
|
|
30
31
|
@id = id
|
|
32
|
+
@graph_thread_id = graph_thread_id || id
|
|
31
33
|
@ctx = context
|
|
32
34
|
@context = context
|
|
33
35
|
@entry_point = entry_point
|
|
@@ -217,7 +219,7 @@ module Phronomy
|
|
|
217
219
|
return if @done
|
|
218
220
|
|
|
219
221
|
@done = true
|
|
220
|
-
@ctx.set_graph_metadata(thread_id: @
|
|
222
|
+
@ctx.set_graph_metadata(thread_id: @graph_thread_id, phase: :__end__)
|
|
221
223
|
post_terminal_event(:finished, @ctx)
|
|
222
224
|
end
|
|
223
225
|
|
|
@@ -225,7 +227,7 @@ module Phronomy
|
|
|
225
227
|
return if @done
|
|
226
228
|
|
|
227
229
|
@done = true
|
|
228
|
-
@ctx.set_graph_metadata(thread_id: @
|
|
230
|
+
@ctx.set_graph_metadata(thread_id: @graph_thread_id, phase: @current_state)
|
|
229
231
|
post_terminal_event(:halted, @ctx)
|
|
230
232
|
end
|
|
231
233
|
|
|
@@ -248,7 +250,7 @@ module Phronomy
|
|
|
248
250
|
|
|
249
251
|
Phronomy.configuration.logger&.warn(
|
|
250
252
|
"[Phronomy::FSMSession] EventLoop rejected terminal event " \
|
|
251
|
-
|
|
253
|
+
"#{type.inspect} for #{@id}"
|
|
252
254
|
)
|
|
253
255
|
end
|
|
254
256
|
|
|
@@ -69,6 +69,7 @@ module Phronomy
|
|
|
69
69
|
@pool_registry = Phronomy::Concurrency::PoolRegistry.new(
|
|
70
70
|
timer_queue_provider: -> { timer_queue }
|
|
71
71
|
)
|
|
72
|
+
@agent_activations = Phronomy::Agent::ActivationRegistry.new
|
|
72
73
|
@lifecycle_mutex = Mutex.new
|
|
73
74
|
@shutdown_mutex = Mutex.new
|
|
74
75
|
@state = :running
|
|
@@ -107,6 +108,12 @@ module Phronomy
|
|
|
107
108
|
@timer_service.timer_queue
|
|
108
109
|
end
|
|
109
110
|
|
|
111
|
+
# Process-local live Agent executions. Activations are transient runtime
|
|
112
|
+
# state and deliberately do not belong to Persistence.
|
|
113
|
+
def __agent_activations
|
|
114
|
+
@agent_activations
|
|
115
|
+
end
|
|
116
|
+
|
|
110
117
|
def event_loop
|
|
111
118
|
@lifecycle_mutex.synchronize do
|
|
112
119
|
case @state
|
|
@@ -66,7 +66,7 @@ module Phronomy
|
|
|
66
66
|
|
|
67
67
|
def save(agent_id, expected_revision:, root:)
|
|
68
68
|
@owner.synchronize do
|
|
69
|
-
current =
|
|
69
|
+
current = @owner.state[:agents].fetch(agent_id.to_s) { raise NotFoundError, "agent not found: #{agent_id}" }
|
|
70
70
|
unless current.agent_revision == expected_revision
|
|
71
71
|
raise ConflictError,
|
|
72
72
|
"agent revision conflict: expected #{expected_revision}, actual #{current.agent_revision}"
|
|
@@ -162,7 +162,7 @@ module Phronomy
|
|
|
162
162
|
|
|
163
163
|
def save(execution_id, expected_revision:, execution:)
|
|
164
164
|
@owner.synchronize do
|
|
165
|
-
current =
|
|
165
|
+
current = @owner.state[:executions].fetch(execution_id.to_s) { raise NotFoundError, "execution not found: #{execution_id}" }
|
|
166
166
|
unless current.execution_revision == expected_revision
|
|
167
167
|
raise ConflictError,
|
|
168
168
|
"execution revision conflict: expected #{expected_revision}, actual #{current.execution_revision}"
|
|
@@ -209,31 +209,112 @@ module Phronomy
|
|
|
209
209
|
end
|
|
210
210
|
end
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
class WorkflowStates
|
|
213
|
+
def initialize(owner) = @owner = owner
|
|
214
|
+
|
|
215
|
+
def load(thread_id)
|
|
216
|
+
@owner.synchronize do
|
|
217
|
+
record = @owner.workflow_state_data[thread_id.to_s]
|
|
218
|
+
next nil unless record
|
|
219
|
+
|
|
220
|
+
{
|
|
221
|
+
snapshot: @owner.deep_dup_workflow_value(record.fetch(:snapshot)),
|
|
222
|
+
revision: record.fetch(:revision)
|
|
223
|
+
}.freeze
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def save(thread_id, expected_revision:, snapshot:)
|
|
228
|
+
@owner.synchronize do
|
|
229
|
+
key = thread_id.to_s
|
|
230
|
+
current = @owner.workflow_state_data[key]
|
|
231
|
+
actual_revision = current&.fetch(:revision)
|
|
232
|
+
unless actual_revision == expected_revision
|
|
233
|
+
raise ConflictError,
|
|
234
|
+
"workflow state revision conflict for #{key}: " \
|
|
235
|
+
"expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
next_revision = actual_revision ? actual_revision + 1 : 1
|
|
239
|
+
@owner.workflow_state_data[key] = {
|
|
240
|
+
snapshot: @owner.deep_dup_workflow_value(snapshot),
|
|
241
|
+
revision: next_revision
|
|
242
|
+
}
|
|
243
|
+
next_revision
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def delete(thread_id, expected_revision:)
|
|
248
|
+
@owner.synchronize do
|
|
249
|
+
key = thread_id.to_s
|
|
250
|
+
current = @owner.workflow_state_data[key]
|
|
251
|
+
actual_revision = current&.fetch(:revision)
|
|
252
|
+
unless actual_revision == expected_revision
|
|
253
|
+
raise ConflictError,
|
|
254
|
+
"workflow state revision conflict for #{key}: " \
|
|
255
|
+
"expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
|
|
256
|
+
end
|
|
257
|
+
@owner.workflow_state_data.delete(key)
|
|
258
|
+
end
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
attr_reader :state, :workflow_state_data
|
|
213
264
|
|
|
214
265
|
def initialize
|
|
215
266
|
@monitor = Monitor.new
|
|
216
267
|
@state = {contents: {}, agents: {}, journals: {}, executions: {}}
|
|
268
|
+
@workflow_state_data = {}
|
|
217
269
|
@contents = Contents.new(self)
|
|
218
270
|
@agents = Agents.new(self)
|
|
219
271
|
@journals = Journals.new(self)
|
|
220
272
|
@executions = Executions.new(self)
|
|
221
|
-
@
|
|
222
|
-
super(
|
|
223
|
-
|
|
273
|
+
@workflow_states = WorkflowStates.new(self)
|
|
274
|
+
super(
|
|
275
|
+
contents: @contents,
|
|
276
|
+
agents: @agents,
|
|
277
|
+
journals: @journals,
|
|
278
|
+
executions: @executions,
|
|
279
|
+
workflow_states: @workflow_states
|
|
280
|
+
)
|
|
224
281
|
end
|
|
225
282
|
|
|
226
283
|
def capabilities
|
|
227
284
|
{atomic_all: true, atomic_admission: true, optimistic_revision: true}.freeze
|
|
228
285
|
end
|
|
229
286
|
|
|
287
|
+
def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
|
|
288
|
+
synchronize do
|
|
289
|
+
stored = @state[:agents][agent_id.to_s]
|
|
290
|
+
unless stored
|
|
291
|
+
raise NotFoundError, "Agent not found: #{agent_id}"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
if stored.agent_revision != agent_revision
|
|
295
|
+
raise ConflictError,
|
|
296
|
+
"agent revision conflict: expected #{agent_revision}, actual #{stored.agent_revision}"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
actual_position = Array(@state[:journals][agent_id.to_s]).length
|
|
300
|
+
if actual_position != journal_position
|
|
301
|
+
raise ConflictError,
|
|
302
|
+
"journal position conflict: expected #{journal_position}, actual #{actual_position}"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
true
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
230
309
|
def transaction
|
|
231
310
|
synchronize do
|
|
232
|
-
|
|
311
|
+
state_snapshot = Marshal.load(Marshal.dump(@state))
|
|
312
|
+
workflow_snapshot = deep_dup_workflow_value(@workflow_state_data)
|
|
233
313
|
begin
|
|
234
314
|
yield self
|
|
235
315
|
rescue
|
|
236
|
-
@state
|
|
316
|
+
@state.replace(state_snapshot)
|
|
317
|
+
@workflow_state_data.replace(workflow_snapshot)
|
|
237
318
|
raise
|
|
238
319
|
end
|
|
239
320
|
end
|
|
@@ -242,6 +323,30 @@ module Phronomy
|
|
|
242
323
|
def synchronize(&block)
|
|
243
324
|
@monitor.synchronize(&block)
|
|
244
325
|
end
|
|
326
|
+
|
|
327
|
+
# Workflow fields historically accepted ordinary Ruby values in the
|
|
328
|
+
# in-memory store. Keep that contract without forcing the Agent durable
|
|
329
|
+
# state Marshal snapshot to serialize arbitrary Workflow values.
|
|
330
|
+
def deep_dup_workflow_value(value)
|
|
331
|
+
case value
|
|
332
|
+
when Hash
|
|
333
|
+
value.each_with_object({}) do |(key, child), result|
|
|
334
|
+
result[deep_dup_workflow_value(key)] = deep_dup_workflow_value(child)
|
|
335
|
+
end
|
|
336
|
+
when Array
|
|
337
|
+
value.map { |child| deep_dup_workflow_value(child) }
|
|
338
|
+
when NilClass, Symbol, Integer, Float, TrueClass, FalseClass
|
|
339
|
+
value
|
|
340
|
+
else
|
|
341
|
+
return value if value.frozen?
|
|
342
|
+
|
|
343
|
+
begin
|
|
344
|
+
value.dup
|
|
345
|
+
rescue TypeError
|
|
346
|
+
value
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
245
350
|
end
|
|
246
351
|
end
|
|
247
352
|
end
|
data/lib/phronomy/persistence.rb
CHANGED
|
@@ -5,31 +5,134 @@ module Phronomy
|
|
|
5
5
|
class ConflictError < Phronomy::Error; end
|
|
6
6
|
class NotFoundError < Phronomy::Error; end
|
|
7
7
|
class UnsupportedBackendError < Phronomy::Error; end
|
|
8
|
+
class SerializationError < Phronomy::Error; end
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
REQUIRED_CAPABILITIES = {
|
|
11
|
+
atomic_all: true,
|
|
12
|
+
atomic_admission: true,
|
|
13
|
+
optimistic_revision: true
|
|
14
|
+
}.freeze
|
|
10
15
|
|
|
11
|
-
|
|
16
|
+
# Durable repository accessors supplied by a Persistence backend.
|
|
17
|
+
#
|
|
18
|
+
# The repository objects are part of the Backend SPI. They may be private
|
|
19
|
+
# implementation classes owned by the backend; they do not need to inherit
|
|
20
|
+
# from Phronomy repository base classes.
|
|
21
|
+
#
|
|
22
|
+
# @return [Object] content-addressed immutable content repository
|
|
23
|
+
# @api public
|
|
24
|
+
attr_reader :contents
|
|
25
|
+
|
|
26
|
+
# @return [Object] AgentRoot repository
|
|
27
|
+
# @api public
|
|
28
|
+
attr_reader :agents
|
|
29
|
+
|
|
30
|
+
# @return [Object] append-only Agent Journal repository
|
|
31
|
+
# @api public
|
|
32
|
+
attr_reader :journals
|
|
33
|
+
|
|
34
|
+
# @return [Object] AgentExecution repository
|
|
35
|
+
# @api public
|
|
36
|
+
attr_reader :executions
|
|
37
|
+
|
|
38
|
+
# @return [Object] durable Workflow snapshot repository
|
|
39
|
+
# @api public
|
|
40
|
+
attr_reader :workflow_states
|
|
41
|
+
|
|
42
|
+
# Initializes a Persistence backend with its durable repositories.
|
|
43
|
+
#
|
|
44
|
+
# Subclasses normally construct backend-specific repository objects and then
|
|
45
|
+
# call +super+. The backend must advertise every capability in
|
|
46
|
+
# {REQUIRED_CAPABILITIES}; construction fails fast otherwise.
|
|
47
|
+
#
|
|
48
|
+
# @param contents [Object]
|
|
49
|
+
# @param agents [Object]
|
|
50
|
+
# @param journals [Object]
|
|
51
|
+
# @param executions [Object]
|
|
52
|
+
# @param workflow_states [Object]
|
|
53
|
+
# @raise [UnsupportedBackendError] when a required capability is missing
|
|
54
|
+
# @api public
|
|
55
|
+
def initialize(contents:, agents:, journals:, executions:, workflow_states:)
|
|
12
56
|
@contents = contents
|
|
13
57
|
@agents = agents
|
|
14
58
|
@journals = journals
|
|
15
59
|
@executions = executions
|
|
16
|
-
@
|
|
60
|
+
@workflow_states = workflow_states
|
|
17
61
|
validate_capabilities!
|
|
18
62
|
end
|
|
19
63
|
|
|
64
|
+
# Declares storage semantics provided by this backend.
|
|
65
|
+
#
|
|
66
|
+
# Required meanings:
|
|
67
|
+
# - +atomic_all+: all durable repositories can participate in one atomic
|
|
68
|
+
# transaction domain.
|
|
69
|
+
# - +atomic_admission+: Agent execution admission is atomic; at most one
|
|
70
|
+
# active/suspended execution may be admitted for one Agent. This does not
|
|
71
|
+
# mean cross-process Workflow admission or distributed locking.
|
|
72
|
+
# - +optimistic_revision+: Agent, Execution, Workflow revision checks and
|
|
73
|
+
# Journal position checks provide compare-and-swap conflict detection.
|
|
74
|
+
#
|
|
75
|
+
# @return [Hash{Symbol => Boolean}]
|
|
76
|
+
# @api public
|
|
20
77
|
def capabilities
|
|
21
|
-
{
|
|
78
|
+
{
|
|
79
|
+
atomic_all: false,
|
|
80
|
+
atomic_admission: false,
|
|
81
|
+
optimistic_revision: false
|
|
82
|
+
}.freeze
|
|
22
83
|
end
|
|
23
84
|
|
|
85
|
+
# Executes one atomic durable transaction.
|
|
86
|
+
#
|
|
87
|
+
# The object yielded to the block is a transaction-scoped Persistence view.
|
|
88
|
+
# It must respond to +contents+, +agents+, +journals+, +executions+,
|
|
89
|
+
# +workflow_states+, and +assert_agent_watermark!+. It may be +self+, but
|
|
90
|
+
# backends are free to yield a separate transaction view backed by a checked
|
|
91
|
+
# out connection/session.
|
|
92
|
+
#
|
|
93
|
+
# If the block raises, mutations made through the transaction view must not
|
|
94
|
+
# be committed. Storage failures whose commit outcome is fundamentally
|
|
95
|
+
# unknown remain backend/database failures; Phronomy does not claim
|
|
96
|
+
# exactly-once semantics for such failures.
|
|
97
|
+
#
|
|
98
|
+
# @yieldparam transaction_view [Object]
|
|
99
|
+
# @return [Object] the block result
|
|
100
|
+
# @raise [UnsupportedBackendError] when atomic transactions are unavailable
|
|
101
|
+
# @api public
|
|
24
102
|
def transaction
|
|
25
103
|
raise UnsupportedBackendError, "#{self.class} does not provide atomic_all"
|
|
26
104
|
end
|
|
27
105
|
|
|
106
|
+
# Verifies that a live Agent still owns the durable base it hydrated.
|
|
107
|
+
#
|
|
108
|
+
# This is a Backend SPI operation invoked by Phronomy at durable barriers.
|
|
109
|
+
# Ordinary application code should not call it directly. The backend must
|
|
110
|
+
# compare the stored Agent revision and current Journal position against the
|
|
111
|
+
# supplied watermark in the same storage consistency view used by subsequent
|
|
112
|
+
# writes in the surrounding transaction.
|
|
113
|
+
#
|
|
114
|
+
# The method is a precondition check only. It must not reload or return
|
|
115
|
+
# replacement mutable Agent state; the live Agent remains the logical owner.
|
|
116
|
+
#
|
|
117
|
+
# @param agent_id [String]
|
|
118
|
+
# @param agent_revision [Integer]
|
|
119
|
+
# @param journal_position [Integer]
|
|
120
|
+
# @return [true]
|
|
121
|
+
# @raise [NotFoundError] when the Agent does not exist
|
|
122
|
+
# @raise [ConflictError] when either durable watermark component differs
|
|
123
|
+
# @raise [UnsupportedBackendError] when the backend does not implement the check
|
|
124
|
+
# @api public
|
|
125
|
+
def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
|
|
126
|
+
raise UnsupportedBackendError,
|
|
127
|
+
"#{self.class} does not provide Agent durable-watermark checks"
|
|
128
|
+
end
|
|
129
|
+
|
|
28
130
|
private
|
|
29
131
|
|
|
30
132
|
def validate_capabilities!
|
|
31
|
-
|
|
32
|
-
|
|
133
|
+
missing = REQUIRED_CAPABILITIES.reject do |key, value|
|
|
134
|
+
capabilities[key] == value
|
|
135
|
+
end
|
|
33
136
|
return if missing.empty?
|
|
34
137
|
|
|
35
138
|
raise UnsupportedBackendError,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.shared_examples "a persistence content store" do
|
|
4
|
+
let(:content_store) { persistence.contents }
|
|
5
|
+
|
|
6
|
+
it "returns the same content_id for the same bytes" do
|
|
7
|
+
first = content_store.put("same".b, canonicalization_version: 1)
|
|
8
|
+
second = content_store.put("same".b, canonicalization_version: 1)
|
|
9
|
+
|
|
10
|
+
expect(second).to eq(first)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "round-trips binary bytes" do
|
|
14
|
+
bytes = "\x00\xFFpayload".b
|
|
15
|
+
content_id = content_store.put(bytes, canonicalization_version: 1)
|
|
16
|
+
|
|
17
|
+
expect(content_store.fetch(content_id)).to eq(bytes)
|
|
18
|
+
expect(content_store.exist?(content_id)).to be(true)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "raises NotFoundError for a missing content_id" do
|
|
22
|
+
expect do
|
|
23
|
+
content_store.fetch("sha256:#{"0" * 64}")
|
|
24
|
+
end.to raise_error(Phronomy::Persistence::NotFoundError)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "isolates durable bytes from mutation of fetched values" do
|
|
28
|
+
content_id = content_store.put("immutable".b, canonicalization_version: 1)
|
|
29
|
+
fetched = content_store.fetch(content_id)
|
|
30
|
+
fetched << "-caller-change"
|
|
31
|
+
|
|
32
|
+
expect(content_store.fetch(content_id)).to eq("immutable".b)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "supports UTF-8 text helpers" do
|
|
36
|
+
content_id = content_store.put_text("Grüße")
|
|
37
|
+
|
|
38
|
+
expect(content_store.fetch_text(content_id)).to eq("Grüße")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "supports canonical JSON helpers" do
|
|
42
|
+
value = {
|
|
43
|
+
"z" => [1, true, nil],
|
|
44
|
+
"a" => {"message" => "hello"}
|
|
45
|
+
}
|
|
46
|
+
content_id = content_store.put_json(value)
|
|
47
|
+
|
|
48
|
+
expect(content_store.fetch_json(content_id)).to eq(value)
|
|
49
|
+
end
|
|
50
|
+
end
|