Skip to content

Commit 6771a76

Browse files
committed
fix: Align payload and timeout behavior
Guard and isolate payload projections, honor configured byte limits, and preserve generated defaults within each committed snapshot. Normalize timeout telemetry to the shared JavaScript contract. Let SQLite lock holders run during background busy waits on older Rails versions, reinstalling the yielding handler on each attempt. Cover the behavior with shared fixtures, real adapter regressions, and reversal checks.
1 parent d9667bd commit 6771a76

16 files changed

Lines changed: 282 additions & 41 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
## Unreleased
44

5+
- Guard personalized payload projections against state changes, staged work,
6+
and application database writes. Each payload gets an isolated actor from
7+
the committed snapshot and honors `max_payload_bytes`, matching JavaScript.
8+
- Preserve timeout wait reasons, activation owner IDs, and activation generations
9+
in portable telemetry using the shared camelCase fields and reason values.
10+
- Use a yielding SQLite busy handler for background transactions so concurrent
11+
writers can finish on Rails 7.1 and 7.2. Preserve configured wait limits and
12+
synchronous deadlines; cover contention with a coordinated lock regression.
13+
514
- Reject query and observable state mutation and staged durable work with
615
terminal `QueryMutatedState` errors. Cover individual snapshot projections and
716
preserve ordinary operations' already-staged work while reading projections,

‎compatibility/sync-timeout.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[
2+
{ "rubyReason": "actor_paused", "waitingOn": "actorPaused" },
3+
{ "rubyReason": "activation_held", "waitingOn": "activationHeld" },
4+
{ "rubyReason": "earlier_message", "waitingOn": "earlierMessage" },
5+
{ "rubyReason": "message_claimed", "waitingOn": "messageClaimed" },
6+
{ "rubyReason": "not_yet_available", "waitingOn": "notYetAvailable" },
7+
{ "rubyReason": "ready_unclaimed", "waitingOn": "readyUnclaimed" },
8+
{ "rubyReason": "database_contention", "waitingOn": "databaseContention" },
9+
{ "rubyReason": "unknown", "waitingOn": "unknown" }
10+
]

‎docs/observability.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ Portable `polling.interval_changed` events carry `previousIntervalMilliseconds`,
4747
`currentIntervalMilliseconds`, and a string `reason`. Ruby converts its native
4848
second-based notification values while preserving the original notification.
4949

50+
## Synchronous timeout diagnostics
51+
52+
`solid_objects.sync.timeout` includes `waitingOn`, `activationOwnerId`, and
53+
`activationGeneration` in `attributes`. Generations are decimal strings;
54+
unavailable activation fields are null. Both runtimes use the same wait reasons:
55+
`actorPaused`, `activationHeld`, `earlierMessage`, `messageClaimed`,
56+
`notYetAvailable`, `readyUnclaimed`, `databaseContention`, and `unknown`.
57+
Ruby's exception attributes and Active Support notifications retain their native
58+
snake_case names and reason values. The portable instrumentation envelope uses
59+
the shared camelCase contract.
60+
5061
## Metrics and tracing
5162

5263
Metrics are sample descriptions. Exporting them is opt-in: the runtime does not

‎docs/roadmap.md‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
## Implemented and tested
44

55
- Portable telemetry with a shared JSON schema, metric samples, isolated observer
6-
callbacks, and bounded authorized actor diagnostics. See [observability](observability.md).
6+
callbacks, bounded authorized actor diagnostics, and matching timeout wait
7+
reasons and activation metadata. See [observability](observability.md).
78

89
- Rails engine, install generator, migration, and CLI
910
- Explicit actor registry, references, JSON state, and state migrations
10-
- Queries and observable projections reject state mutation and staged durable
11-
work with terminal `QueryMutatedState` errors, matching JavaScript. Shared JSON
12-
fixtures preserve reserved property names as ordinary data in both runtimes.
11+
- Queries, observable projections, and personalized payloads reject state mutation
12+
and staged durable work with `QueryMutatedState`, matching JavaScript. Query
13+
and observable violations fail terminally; a payload violation is confined to
14+
that payload. Shared JSON fixtures preserve reserved property names as ordinary
15+
data in both runtimes.
1316
- Fluent direct synchronous RPC, configured `sync`, and durable `async`
1417
- Durable message history plus ready/claimed membership tables
1518
- Concurrent sequence allocation and actor creation. An enqueue finds the
@@ -61,7 +64,9 @@
6164
subscriber's authorization context, fenced by actor revision, resolved through
6265
`payload_authorization_context` so the block and `authorize_query` see the
6366
same subject a controller render passes, and confined so one failing payload
64-
cannot reject the subscription or stop its siblings
67+
cannot reject the subscription or stop its siblings. Each payload uses an
68+
isolated actor from the same committed snapshot, prevents application database
69+
writes, and enforces the configured `max_payload_bytes` limit
6570
- Reconciliation read APIs
6671
- Installation doctor, authorization reference, fit guide, and legacy-state
6772
migration cookbook. The doctor names every column that a migration after the

‎lib/solid_objects/actor.rb‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -545,26 +545,26 @@ def intent_count
545545
reminder_intents.length + outbound_message_intents.length
546546
end
547547

548-
private
549-
550-
attr_reader :effect_intents,
551-
:effect_recovery_intents,
552-
:commit_action_intents,
553-
:reminder_intents,
554-
:outbound_message_intents
555-
556548
# @rbs (String) { () -> untyped } -> untyped
557549
def read_projection(operation)
558550
state_before = state.to_h
559551
intents_before = intent_snapshot
560552
result = guard_application_writes(operation) { yield }
561553
unless state.to_h == state_before && intent_snapshot == intents_before
562-
raise QueryMutatedState, "observables must not mutate actor state or stage durable work"
554+
raise QueryMutatedState, "projections must not mutate actor state or stage durable work"
563555
end
564556

565557
result
566558
end
567559

560+
private
561+
562+
attr_reader :effect_intents,
563+
:effect_recovery_intents,
564+
:commit_action_intents,
565+
:reminder_intents,
566+
:outbound_message_intents
567+
568568
# @rbs () -> Array[Array[Hash[Symbol, untyped]]]
569569
def intent_snapshot
570570
[ effect_intents, effect_recovery_intents, commit_action_intents, reminder_intents, outbound_message_intents ].map do |intents|

‎lib/solid_objects/actor_snapshot.rb‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class ActorSnapshot
77
# @rbs @actor: Actor
88
# @rbs @instance_id: Integer
99
# @rbs @revision: Integer
10+
# @rbs @state_data: Hash[String, untyped]
1011
# @rbs @observable_values: Hash[String, untyped]?
1112
# @rbs @observable_value_cache: Hash[String, untyped]
1213

@@ -24,6 +25,7 @@ def initialize(reference)
2425
end
2526
@instance_id = @instance&.id || 0
2627
@revision = @instance&.state_revision || 0
28+
@state_data = State.new(actor_class.definition.state_definition, migrated_state).to_h
2729
@actor = build_actor
2830
@observable_values = nil
2931
@observable_value_cache = {}
@@ -43,14 +45,23 @@ def observable_value(name)
4345
Serialization.readonly_copy(actor.observable_value(observable_name))
4446
end
4547

48+
# @rbs () -> Actor
49+
def build_actor
50+
actor_class.new(
51+
actor_id: reference.actor_id,
52+
state: State.new(actor_class.definition.state_definition, @state_data),
53+
instance_id: instance&.id
54+
)
55+
end
56+
4657
private
4758

4859
attr_reader :instance
4960

50-
# @rbs () -> Actor
51-
def build_actor
61+
# @rbs () -> Hash[String, untyped]
62+
def migrated_state
5263
state_version = instance&.state_version || actor_class.state_version
53-
state_data = ApplicationWriteGuard.call(
64+
ApplicationWriteGuard.call(
5465
actor_type: reference.actor_type,
5566
actor_id: reference.actor_id,
5667
operation: "state_migration"
@@ -60,11 +71,6 @@ def build_actor
6071
instance&.state || {}
6172
)
6273
end
63-
actor_class.new(
64-
actor_id: reference.actor_id,
65-
state: State.new(actor_class.definition.state_definition, state_data),
66-
instance_id: @instance&.id
67-
)
6874
end
6975
end
7076
end

‎lib/solid_objects/database_adapters/sqlite.rb‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ def current_time_expression
2222
def transaction(&block)
2323
return with_lock_retry { super } if SyncDeadline.active?
2424

25-
with_busy_retry { super }
25+
with_busy_retry do
26+
with_connection do |connection|
27+
timeout = configured_busy_handler_timeout(connection)
28+
connection.raw_connection.busy_handler_timeout = timeout if timeout
29+
super
30+
end
31+
end
2632
end
2733

2834
# A write outside a synchronous deadline has no Ruby-level budget, so it

‎lib/solid_objects/payload_broadcast.rb‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
module SolidObjects
44
class PayloadBroadcast
5-
MAXIMUM_PAYLOAD_BYTES = 1_048_576
65
REVISION_OBSERVABLE = "solid_objects.revision"
76

87
# @rbs @snapshot: ActorSnapshot
@@ -42,7 +41,7 @@ def call
4241
def rendered_payload(handler)
4342
payload = Serialization.dump(
4443
evaluated_payload(handler),
45-
max_bytes: MAXIMUM_PAYLOAD_BYTES
44+
max_bytes: SolidObjects.configuration.max_payload_bytes
4645
)
4746
return payload if payload.is_a?(Hash) || payload.is_a?(Array)
4847

@@ -56,10 +55,12 @@ def rendered_payload(handler)
5655
# unaffected.
5756
# @rbs (ActorDefinition::Handler) -> untyped
5857
def evaluated_payload(handler)
59-
actor = snapshot.actor
60-
actor.instance_exec(actor, authorization_context, &handler.block)
58+
actor = snapshot.build_actor
59+
actor.read_projection("payload.#{name}") do
60+
actor.instance_exec(actor, authorization_context, &handler.block)
61+
end
6162
rescue NameError => error
62-
raise unless class_level_receiver?(error)
63+
raise unless class_level_receiver?(error, actor)
6364

6465
raise InvalidPayloadBroadcast,
6566
"payload broadcast #{name.inspect} called #{error.name.inspect} on the " \
@@ -70,9 +71,9 @@ def evaluated_payload(handler)
7071
# Distinguishes a block that relied on the old class-level receiver from an
7172
# ordinary typo, so the one behaviour change reports itself instead of
7273
# surfacing as an unexplained NameError.
73-
# @rbs (NameError[untyped]) -> bool
74-
def class_level_receiver?(error)
75-
error.receiver.equal?(snapshot.actor) &&
74+
# @rbs (NameError[untyped], Actor?) -> bool
75+
def class_level_receiver?(error, actor)
76+
error.receiver.equal?(actor) &&
7677
snapshot.actor_class.respond_to?(error.name)
7778
rescue ArgumentError, NameError
7879
false

‎lib/solid_objects/telemetry.rb‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ module Telemetry
99
outboxKind truncated broadcastId code role reason processId processKind ownerId componentCount byteCount
1010
thresholdBytes previousRunAt nextRunAt name commitAction payload
1111
previousIntervalMilliseconds currentIntervalMilliseconds
12+
waitingOn activationOwnerId activationGeneration
1213
].freeze
1314
ALIASES = { "errorClass" => "errorName", "stateRevision" => "revision", "durationMs" => "durationMilliseconds" }.freeze
1415

@@ -89,7 +90,8 @@ def safe_attributes(payload)
8990
next unless FIELDS.include?(name)
9091
next unless value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
9192

92-
value = value.to_s if !value.nil? && (name.end_with?("Id") || %w[revision sequence generation].include?(name))
93+
value = value.camelize(:lower) if name == "waitingOn" && value.is_a?(String)
94+
value = value.to_s if !value.nil? && (name.end_with?("Id") || %w[revision sequence generation activationGeneration].include?(name))
9395
attributes[name] = value
9496
end
9597
end

‎sig/generated/lib/solid_objects/actor.rbs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,9 @@ module SolidObjects
351351
# @rbs () -> Integer
352352
def intent_count: () -> Integer
353353

354+
# @rbs (String) { () -> untyped } -> untyped
355+
def read_projection: (String) { () -> untyped } -> untyped
356+
354357
private
355358

356359
attr_reader effect_intents: untyped
@@ -363,9 +366,6 @@ module SolidObjects
363366

364367
attr_reader outbound_message_intents: untyped
365368

366-
# @rbs (String) { () -> untyped } -> untyped
367-
def read_projection: (String) { () -> untyped } -> untyped
368-
369369
# @rbs () -> Array[Array[Hash[Symbol, untyped]]]
370370
def intent_snapshot: () -> Array[Array[Hash[Symbol, untyped]]]
371371

0 commit comments

Comments
 (0)