Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@
another process, so its write cannot clear that cache. A poll inside a request,
a job, or `rails runner` reported the first answer forever. The synchronous
wait already read uncached.
- Add reminder cancellation. `unschedule` removes one reminder by operation and
optional key, or by the handle `schedule` now returns. `unschedule_all`
removes every key of one operation. Both stage an intent, so a cancel commits
with the state change that decided it, and a turn that raises cancels nothing.
- Add reminder reading. `reminder` returns a `ReminderStatus` or `nil`, and
`reminders` lists every key of one operation. A read applies the intents
staged in the current turn, so it agrees with what the commit will write. An
actor reads its own schedule from every path, including activation hooks and
observables, because it carries its instance rather than reading an ambient
context that only message dispatch establishes.
- Leave a one-shot reminder that already fired out of `reminder` and
`reminders`. Its row stays as `completed`, so a next-run lookup reported an
old time rather than nothing, and an existence check refused to re-arm an
alarm that could never fire again.
- Refuse an unknown operation in `reminder`, `reminders`, `unschedule`, and
`unschedule_all`. `schedule` already raised `UnknownMessage` for one, so a
typo cancelled nothing quietly and left a recurring reminder running.
- A cancel cannot recall an occurrence the scheduler already turned into a
message. It does pre-empt one the scheduler claimed but has not yet enqueued.
- `schedule` now returns a reminder handle instead of `nil`. An operation that
ends with `schedule` and relies on an implicit `nil` result should return
`nil` explicitly, as `emit` required in 0.15.0.

## 0.15.2 - 2026-09-21

Expand Down
8 changes: 8 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@
listed here while broken in that worker: the scheduler reached a constant the
caller path happened to load, so reminders never fired in production and
every in-process test still passed
- Reminder cancellation and reading. `schedule` returns a durable handle,
`unschedule` and `unschedule_all` cancel by name, key, or handle, and
`reminder` and `reminders` read the schedule. A cancel is an intent, so it
commits with the state change that decided it. A read applies the intents
staged so far, so it agrees with what the commit will leave behind. A cancel
cannot recall an occurrence the scheduler already turned into a message. It
does pre-empt one the scheduler claimed but has not yet enqueued, and the
scheduler treats that as ordinary work rather than a failure
- Durable invalidation-only observable broadcasts by default, explicit
`broadcast: :value` scalar Turbo replacement, keyed ERB components, signed
component locals, and authorized replace or morph refresh. Default
Expand Down
19 changes: 13 additions & 6 deletions lib/solid_objects/activation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
module SolidObjects
class Activation
# @rbs @lease: Lease
# @rbs @actor_id: String
# @rbs @instance_id: Integer
# @rbs @actor_class: Class
# @rbs @actor: Actor
# @rbs @last_used_at: Float
Expand All @@ -17,6 +19,8 @@ def initialize(lease:)
Instance.find(lease.instance_id)
end
@actor_class = SolidObjects.registry.fetch(instance.actor_type)
@actor_id = instance.actor_id
@instance_id = instance.id
@actor = build_actor(instance)
@last_used_at = monotonic_now
@pass_exhausted = false
Expand Down Expand Up @@ -86,10 +90,7 @@ def yield_ready_messages

# @rbs (Hash[String, untyped]) -> void
def restore_state(state_data)
@actor = actor_class.new(
actor_id: actor.actor_id,
state: State.new(actor_class.definition.state_definition, state_data)
)
@actor = new_actor(state_data)
end

# @rbs () -> void
Expand Down Expand Up @@ -156,9 +157,15 @@ def build_actor(instance)
) do
actor_class.definition.migrate_state(instance.state_version, instance.state)
end
new_actor(state_data)
end

# @rbs (Hash[String, untyped]) -> Actor
def new_actor(state_data)
actor_class.new(
actor_id: instance.actor_id,
state: State.new(actor_class.definition.state_definition, state_data)
actor_id: @actor_id,
state: State.new(actor_class.definition.state_definition, state_data),
instance_id: @instance_id
)
end

Expand Down
142 changes: 134 additions & 8 deletions lib/solid_objects/actor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ class Actor
REMINDER_NAME_LIMIT = 191
REMINDER_KEY_SEPARATOR = ":"

REMINDER_HANDLE_KEY = "reminder_name"

ReminderIntent = Data.define(:name, :operation, :at, :arguments, :interval_seconds, :missed_policy)
UnscheduleIntent = Data.define(:name)
UnscheduleAllIntent = Data.define(:operation)
ReminderStatus = Data.define(:name, :operation, :key, :next_run_at, :interval_seconds,
:missed_policy, :occurrence, :status, :handle)
OutboundMessageIntent = Data.define(:actor_type, :actor_id, :operation, :arguments, :available_at, :idempotency_key)

class << self
Expand Down Expand Up @@ -152,10 +158,11 @@ def default_actor_type

attr_reader :actor_id, :state

# @rbs (actor_id: String, state: State) -> void
def initialize(actor_id:, state:)
# @rbs (actor_id: String, state: State, ?instance_id: Integer?) -> void
def initialize(actor_id:, state:, instance_id: nil)
@actor_id = actor_id
@state = state
@instance_id = instance_id
Comment thread
greptile-apps[bot] marked this conversation as resolved.
@effect_intents = []
@effect_recovery_intents = []
@commit_action_intents = []
Expand Down Expand Up @@ -255,20 +262,139 @@ def schedule(at:, every: nil, missed: :latest, key: nil)
actor_type: self.class.actor_type,
handlers: self.class.definition.messages
) do |operation, arguments|
ReminderIntent.new(
name: reminder_name(operation:, key: reminder_key),
name = reminder_name(operation:, key: reminder_key)
reminder_intents << ReminderIntent.new(
name:,
operation: operation.to_s,
at:,
arguments: Serialization.dump(arguments),
interval_seconds:,
missed_policy:
).tap do |intent|
reminder_intents << intent
end
nil
)
{ REMINDER_HANDLE_KEY => name }
end
end

# @rbs (Symbol | String | reminder_handle, ?key: (String | Symbol | Integer)?) -> nil
def unschedule(operation_or_handle, key: nil)
return unschedule_name(handle_name(operation_or_handle, key:)) if operation_or_handle.is_a?(Hash)

validated_reminder_operation(operation_or_handle)
unschedule_name(reminder_name(operation: operation_or_handle, key: validated_reminder_key(key)))
end

# @rbs (Symbol | String) -> nil
def unschedule_all(operation)
reminder_intents << UnscheduleAllIntent.new(operation: validated_reminder_operation(operation))
nil
end

# @rbs (Symbol | String | reminder_handle, ?key: (String | Symbol | Integer)?) -> ReminderStatus?
def reminder(operation_or_handle, key: nil)
return reminder_view[handle_name(operation_or_handle, key:)] if operation_or_handle.is_a?(Hash)

validated_reminder_operation(operation_or_handle)
reminder_view[reminder_name(operation: operation_or_handle, key: validated_reminder_key(key))]
end

# @rbs (Symbol | String) -> Array[ReminderStatus]
def reminders(operation)
wanted = validated_reminder_operation(operation)
reminder_view.each_value.select { |status| status.operation == wanted }
end

attr_reader :instance_id

# @rbs (Symbol | String) -> String
def validated_reminder_operation(operation)
name = operation.to_s
return name if self.class.definition.messages.key?(name.to_sym)

raise UnknownMessage, "unknown message #{name.inspect} for #{self.class.actor_type}"
end

# @rbs (String) -> nil
def unschedule_name(name)
reminder_intents << UnscheduleIntent.new(name:)
nil
end

# @rbs (reminder_handle, key: untyped) -> String
def handle_name(handle, key:)
raise ArgumentError, "a reminder handle already names its key" unless key.nil?

name = handle[REMINDER_HANDLE_KEY]
unless name.is_a?(String) && !name.empty?
raise InvalidPayload, "expected a reminder handle returned by schedule"
end

name
end

# The view is the committed schedule with this turn's staged intents applied
# in order, so a read agrees with what the commit will leave behind.
# @rbs () -> Hash[String, ReminderStatus]
def reminder_view
reminder_intents.each_with_object(committed_reminders) do |intent, view|
apply_reminder_intent(view, intent)
end
end

# @rbs () -> Hash[String, ReminderStatus]
def committed_reminders
return {} unless instance_id
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Reminder.where(instance_id:).where.not(status: "completed").each_with_object({}) do |row, view|
view[row.name] = reminder_status(
name: row.name,
operation: row.operation,
next_run_at: row.next_run_at,
interval_seconds: row.interval_seconds,
missed_policy: row.missed_policy,
occurrence: row.occurrence,
status: row.status
)
end
end

# @rbs (Hash[String, ReminderStatus], untyped) -> void
def apply_reminder_intent(view, intent)
return view.delete_if { |_name, status| status.operation == intent.operation } if intent.is_a?(UnscheduleAllIntent)
return view.delete(intent.name) if intent.is_a?(UnscheduleIntent)

view[intent.name] = reminder_status(
name: intent.name,
operation: intent.operation,
next_run_at: intent.at,
interval_seconds: intent.interval_seconds,
missed_policy: intent.missed_policy,
occurrence: view[intent.name]&.occurrence || 0,
status: "scheduled"
)
end

# @rbs (name: String, operation: String, next_run_at: Time?, interval_seconds: untyped, missed_policy: String, occurrence: Integer, status: String) -> ReminderStatus
def reminder_status(name:, operation:, next_run_at:, interval_seconds:, missed_policy:, occurrence:, status:)
ReminderStatus.new(
name:,
operation:,
key: reminder_key_of(name:, operation:),
next_run_at:,
interval_seconds: interval_seconds&.to_f,
missed_policy:,
occurrence:,
status:,
handle: { REMINDER_HANDLE_KEY => name }
)
end

# @rbs (name: String, operation: String) -> String?
def reminder_key_of(name:, operation:)
return nil if name == operation

name.delete_prefix("#{operation}#{REMINDER_KEY_SEPARATOR}")
end

# @rbs ((String | Symbol | Integer)?) -> String?
def validated_reminder_key(key)
return nil if key.nil?
Expand Down
3 changes: 2 additions & 1 deletion lib/solid_objects/actor_snapshot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ def build_actor
end
actor_class.new(
actor_id: reference.actor_id,
state: State.new(actor_class.definition.state_definition, state_data)
state: State.new(actor_class.definition.state_definition, state_data),
instance_id: @instance&.id
)
end
end
Expand Down
14 changes: 10 additions & 4 deletions lib/solid_objects/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module SolidObjects

module Context
STORAGE_KEY = :solid_objects_context
Frame = Data.define(:actor, :message, :authorization_context)
Frame = Data.define(:actor, :message, :authorization_context, :instance_id)

class << self
# @rbs () -> Frame?
Expand All @@ -28,10 +28,16 @@ def authorization_context
current&.authorization_context
end

# @rbs (actor: Actor?, message: MessageContext?, authorization_context: untyped) { () -> untyped } -> untyped
def with(actor:, message:, authorization_context: nil)
# @rbs () -> Integer?
def current_instance_id
current&.instance_id
end

# @rbs (actor: Actor?, message: MessageContext?, ?authorization_context: untyped, ?instance_id: Integer?) { () -> untyped } -> untyped
def with(actor:, message:, authorization_context: nil, instance_id: nil)
previous = current
ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = Frame.new(actor:, message:, authorization_context:)
ActiveSupport::IsolatedExecutionState[STORAGE_KEY] =
Frame.new(actor:, message:, authorization_context:, instance_id:)
yield
ensure
ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = previous
Expand Down
17 changes: 17 additions & 0 deletions lib/solid_objects/executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ def enqueue_effects(message:, instance:, intents:)
# @rbs (Instance, Array[Actor::ReminderIntent]) -> Array[Hash[Symbol, untyped]]
def schedule_reminders(instance, intents)
intents.filter_map do |intent|
next cancel_reminder(instance, intent) unless intent.is_a?(Actor::ReminderIntent)

reminder = Reminder.find_or_initialize_by(instance:, name: intent.name)
previous_run_at = reminder.next_run_at
reminder.assign_attributes(
Expand All @@ -300,6 +302,21 @@ def schedule_reminders(instance, intents)
end
end

# A cancel reports nothing, because a reminder that no longer exists did not
# move. Deleting the row rather than marking it keeps a later schedule of the
# same name free of a tombstone.
# @rbs (Instance, Actor::UnscheduleIntent | Actor::UnscheduleAllIntent) -> nil
def cancel_reminder(instance, intent)
scope = Reminder.where(instance:)
scope = if intent.is_a?(Actor::UnscheduleAllIntent)
scope.where(operation: intent.operation)
else
scope.where(name: intent.name)
end
scope.delete_all
nil
end

# Arguments are omitted deliberately: a reminder carries application data
# and this event exists to be logged.
# @rbs (Reminder, Time?) -> Hash[Symbol, untyped]?
Expand Down
7 changes: 7 additions & 0 deletions sig/generated/lib/solid_objects/activation.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ module SolidObjects

@actor_class: Class

@instance_id: Integer

@actor_id: String

@lease: Lease

attr_reader lease: untyped
Expand Down Expand Up @@ -59,6 +63,9 @@ module SolidObjects
# @rbs (Instance) -> Actor
def build_actor: (Instance) -> Actor

# @rbs (Hash[String, untyped]) -> Actor
def new_actor: (Hash[String, untyped]) -> Actor

# @rbs () -> void
def release_lease: () -> void

Expand Down
Loading
Loading