@@ -4,7 +4,11 @@ module SolidObjects
44 class Actor
55 EffectIntent = Data . define ( :name , :arguments , :success_operation , :failure_operation )
66 CommitActionIntent = Data . define ( :name , :arguments )
7- ReminderIntent = Data . define ( :name , :at , :arguments , :interval_seconds , :missed_policy )
7+ # The reminders table holds a name in 191 characters.
8+ REMINDER_NAME_LIMIT = 191
9+ REMINDER_KEY_SEPARATOR = ":"
10+
11+ ReminderIntent = Data . define ( :name , :operation , :at , :arguments , :interval_seconds , :missed_policy )
812 OutboundMessageIntent = Data . define ( :actor_type , :actor_id , :operation , :arguments , :available_at , :idempotency_key )
913
1014 class << self
@@ -197,8 +201,13 @@ def commit_action(name, **arguments)
197201 nil
198202 end
199203
200- # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
201- def schedule ( at :, every : nil , missed : :latest )
204+ # A reminder is identified by its name, and without a key that name is the
205+ # operation, so one actor holds one alarm per operation. A key gives an actor
206+ # an alarm per item it is waiting on, which is what an actor holding a queue
207+ # of scheduled work needs; the key is the caller's own identifier for the
208+ # item, and scheduling the same key again moves that item's alarm.
209+ # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher
210+ def schedule ( at :, every : nil , missed : :latest , key : nil )
202211 interval_seconds = every &.to_f
203212 if interval_seconds && !interval_seconds . positive?
204213 raise ArgumentError , "reminder interval must be positive"
@@ -207,13 +216,15 @@ def schedule(at:, every: nil, missed: :latest)
207216 unless %w[ all latest ] . include? ( missed_policy )
208217 raise ArgumentError , "missed reminder policy must be all or latest"
209218 end
219+ reminder_key = validated_reminder_key ( key )
210220
211221 OperationDispatcher . new (
212222 actor_type : self . class . actor_type ,
213223 handlers : self . class . definition . messages
214224 ) do |operation , arguments |
215225 ReminderIntent . new (
216- name : operation . to_s ,
226+ name : reminder_name ( operation :, key : reminder_key ) ,
227+ operation : operation . to_s ,
217228 at :,
218229 arguments : Serialization . dump ( arguments ) ,
219230 interval_seconds :,
@@ -225,6 +236,45 @@ def schedule(at:, every: nil, missed: :latest)
225236 end
226237 end
227238
239+ # @rbs ((String | Symbol | Integer)?) -> String?
240+ def validated_reminder_key ( key )
241+ return nil if key . nil?
242+
243+ reminder_key = key . to_s
244+ raise ArgumentError , "reminder key must not be empty" if reminder_key . empty?
245+
246+ reminder_key
247+ end
248+
249+ # A keyed name is the operation, a colon, and the key, so an operation
250+ # holding a colon of its own would make two different schedules produce one
251+ # name: an unkeyed "deliver:item" and a "deliver" keyed "item" would share a
252+ # row, and the second would silently take the first one's alarm. Refusing a
253+ # colon in the operation keeps unkeyed names free of colons, which leaves
254+ # the two kinds of name disjoint and lets a key hold colons of its own.
255+ #
256+ # The length is checked on the composed name rather than the key alone,
257+ # because a long operation and a short key can exceed the column just as
258+ # easily as the reverse. Both are refused here rather than at the insert,
259+ # once the turn is already doing work.
260+ # @rbs (operation: Symbol | String, key: String?) -> String
261+ def reminder_name ( operation :, key :)
262+ operation_name = operation . to_s
263+ if operation_name . include? ( REMINDER_KEY_SEPARATOR )
264+ raise ArgumentError ,
265+ "reminder operation #{ operation_name . inspect } must not contain #{ REMINDER_KEY_SEPARATOR . inspect } "
266+ end
267+ return operation_name if key . nil?
268+
269+ name = "#{ operation_name } #{ REMINDER_KEY_SEPARATOR } #{ key } "
270+ if name . length > REMINDER_NAME_LIMIT
271+ raise ArgumentError ,
272+ "reminder name #{ name . length } characters exceeds the #{ REMINDER_NAME_LIMIT } the database holds"
273+ end
274+
275+ name
276+ end
277+
228278 # @rbs (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
229279 def send_to ( reference , available_at : nil , idempotency_key : nil )
230280 actor_class = SolidObjects . registry . fetch ( reference . actor_type )
0 commit comments