riverqueue 0.13.0 → 0.14.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.
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module River::Driver
4
+ # Internal notification transports. Each client owns its listener; SQLite
5
+ # cursors never consume or delete another client's notifications.
6
+ module NotificationListener
7
+ TOPICS = %w[river_control river_insert river_leadership].freeze
8
+
9
+ class Postgres
10
+ def initialize(connection, schema)
11
+ @connection = connection
12
+ @channels = TOPICS.to_h { |topic| ["#{schema}.#{topic}", topic] }
13
+ @channels.each_key { |channel| @connection.exec("LISTEN #{@connection.escape_identifier(channel)}") }
14
+ rescue
15
+ close
16
+ raise
17
+ end
18
+
19
+ def close
20
+ @connection.close unless @connection.finished?
21
+ end
22
+
23
+ def poll(timeout)
24
+ notifications = [] #: Array[untyped]
25
+ @connection.wait_for_notify(timeout) do |channel, _pid, payload|
26
+ notifications << [@channels[channel], payload]
27
+ end
28
+ # Bound each batch so a busy sender cannot prevent shutdown.
29
+ while notifications.length < 1_000 && (notification = @connection.notifies)
30
+ notifications << [@channels[notification[:relname]], notification[:extra]]
31
+ end
32
+ notifications
33
+ end
34
+ end
35
+
36
+ class SQLite
37
+ def initialize(query)
38
+ @query = query
39
+ # Historical requests must not resign a newly started client.
40
+ @last_id = @query.call("SELECT coalesce(max(id), 0) AS id FROM river_notification").first.transform_keys(&:to_sym).fetch(:id).to_i
41
+ end
42
+
43
+ def close
44
+ end
45
+
46
+ def poll(timeout)
47
+ rows = @query.call("SELECT id, topic, payload FROM river_notification WHERE id > #{@last_id} ORDER BY id LIMIT 1000")
48
+ if rows.empty?
49
+ sleep(timeout)
50
+ return []
51
+ end
52
+
53
+ rows.map do |row|
54
+ row = row.transform_keys(&:to_sym)
55
+ @last_id = row.fetch(:id).to_i
56
+ [row.fetch(:topic), row.fetch(:payload)]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module River::Driver
4
- # PostgreSQL-compatible servers differ in conflict detection and notification
4
+ # Postgres-compatible servers differ in conflict detection and notification
5
5
  # support. Keep these rules aligned with Go's PostgresCapabilities.
6
6
  class PostgresCapabilities
7
7
  attr_reader :supports_listen_notify, :unique_insert_mode, :unique_insert_sql
@@ -5,12 +5,19 @@ require "time"
5
5
  module River::Driver
6
6
  # Database operations used by River's worker runtime. Driver gems implement a
7
7
  # small set of raw SQL primitives and include this module so the state machine
8
- # stays identical across ActiveRecord and Sequel.
8
+ # stays identical across ActiveRecord and Sequel. Those private primitives
9
+ # provide execution, quoting, row access, and connection management inside
10
+ # driver modules; callers use the driver's public operations and Ruby values.
9
11
  module Runtime
10
12
  def init_driver
11
13
  postgres_capabilities if runtime_postgres?
12
14
  end
13
15
 
16
+ # Returns the UTC timestamp that writing and reading +time+ preserves.
17
+ def normalize_time(time)
18
+ runtime_postgres? ? time.getutc.floor(6) : time.getutc.round(3)
19
+ end
20
+
14
21
  def postgres_capabilities
15
22
  @postgres_capabilities_cache.fetch(runtime_connection_pool) do
16
23
  row = runtime_query_rows(<<~SQL).first
@@ -27,7 +34,7 @@ module River::Driver
27
34
  end
28
35
 
29
36
  # Notifications and rows must commit together. The insertion drivers call
30
- # this inside their transaction; PostgreSQL defers delivery until commit.
37
+ # this inside their transaction; Postgres defers delivery until commit.
31
38
  private def postgres_notify_insert(params)
32
39
  queues = params.select { |param| param.state == River::JOB_STATE_AVAILABLE }.map(&:queue).uniq
33
40
  return if queues.empty?
@@ -68,19 +75,23 @@ module River::Driver
68
75
  # The cancellation predicate must be part of the completion update so it is
69
76
  # checked against the row we lock, including concurrent cancellations.
70
77
  # :cancelled asks the runtime to apply its normal cancellation/error hooks.
71
- def job_complete(id:, finalized_at:, metadata: nil, now: Time.now.utc)
78
+ def job_complete(id:, finalized_at:, expected_job: nil, metadata: nil, now: Time.now.utc)
72
79
  id = Integer(id)
80
+ predicate = "id = #{id}"
81
+ predicate += " AND #{runtime_job_attempt_predicate(expected_job)}" if expected_job
73
82
  assignments = ["state = 'completed'", "finalized_at = #{runtime_time(finalized_at)}"]
74
83
  assignments << "metadata = #{runtime_merge_metadata(metadata)}" unless metadata.nil? || metadata.empty?
75
84
 
76
- updated_id = runtime_returning_ids(<<~SQL).first
77
- UPDATE river_job SET #{assignments.join(", ")}
78
- WHERE id = #{id} AND state = 'running' AND NOT (#{runtime_cancel_attempted})
79
- RETURNING id
80
- SQL
81
- return runtime_read_job(updated_id) if updated_id
85
+ transaction do
86
+ updated_id = runtime_returning_ids(<<~SQL).first
87
+ UPDATE river_job SET #{assignments.join(", ")}
88
+ WHERE #{predicate} AND state = 'running' AND NOT (#{runtime_cancel_attempted})
89
+ RETURNING id
90
+ SQL
91
+ next runtime_read_job(updated_id) if updated_id
82
92
 
83
- :cancelled if job_get_cancelled_ids([id]).include?(id)
93
+ :cancelled if runtime_returning_ids("SELECT id FROM river_job WHERE #{predicate} AND (#{runtime_cancel_attempted})").any?
94
+ end
84
95
  end
85
96
 
86
97
  def job_delete(id)
@@ -165,14 +176,24 @@ module River::Driver
165
176
  runtime_job_list(params)
166
177
  end
167
178
 
168
- def job_metadata_merge(id, metadata)
169
- updated_id = runtime_returning_ids(<<~SQL).first
170
- UPDATE river_job
171
- SET metadata = #{runtime_merge_metadata(metadata)}
172
- WHERE id = #{Integer(id)}
173
- RETURNING id
174
- SQL
175
- updated_id ? job_get_by_id(updated_id) : nil
179
+ # When supplied, expected_job prevents metadata from crossing into another
180
+ # attempt or state. Keep the update and returned snapshot in one transaction.
181
+ def job_metadata_merge(id, metadata, expected_job = nil)
182
+ predicate = "id = #{Integer(id)}"
183
+ if expected_job
184
+ predicate += " AND #{runtime_job_attempt_predicate(expected_job)}"
185
+ predicate += " AND state = #{runtime_quote(expected_job.state)}"
186
+ end
187
+
188
+ transaction do
189
+ updated_id = runtime_returning_ids(<<~SQL).first
190
+ UPDATE river_job
191
+ SET metadata = #{runtime_merge_metadata(metadata)}
192
+ WHERE #{predicate}
193
+ RETURNING id
194
+ SQL
195
+ updated_id ? job_get_by_id(updated_id) : nil
196
+ end
176
197
  end
177
198
 
178
199
  def job_rescue_stuck(horizon:, retry_policy:, now: Time.now.utc, max: 1_000, logger: nil, rescue_if: nil)
@@ -244,7 +265,9 @@ module River::Driver
244
265
  AND attempt < #{River::MAX_ATTEMPTS_LIMIT}
245
266
  RETURNING id
246
267
  SQL
247
- [updated_id, job_get_by_id(updated_id || id)]
268
+ job = job_get_by_id(updated_id || id)
269
+ runtime_notify("river_insert", queue: job.queue) if updated_id && job
270
+ [updated_id, job]
248
271
  end
249
272
  if !updated_id && job && job.state != "running" && (job.state != "available" || job.scheduled_at > now) && job.attempt >= River::MAX_ATTEMPTS_LIMIT
250
273
  raise ArgumentError, "cannot retry a job with #{River::MAX_ATTEMPTS_LIMIT} or more attempts"
@@ -252,31 +275,18 @@ module River::Driver
252
275
  job
253
276
  end
254
277
 
278
+ # Makes due scheduled and retryable jobs available. A job is discarded
279
+ # instead if another row already holds its unique key, or if an earlier
280
+ # job in the same pass shares that key. The queries find these conflicts
281
+ # before they write, so the pass never needs a savepoint to recover from a
282
+ # failed write.
255
283
  def job_schedule(now: Time.now.utc, max: 1_000)
256
284
  transaction do
257
- # Hold each selected row until its transition (including uniqueness
258
- # conflict handling) finishes. A concurrent retry may change its due time.
259
- lock = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
260
- ids = runtime_query_rows(<<~SQL).map { |row| runtime_value(row, :id).to_i }
261
- SELECT id FROM river_job
262
- WHERE state IN ('retryable', 'scheduled') AND scheduled_at <= #{runtime_time(now)}
263
- ORDER BY priority, scheduled_at, id
264
- LIMIT #{Integer(max)} #{lock}
265
- SQL
266
- ids.each do |id|
267
- transaction do
268
- runtime_execute("UPDATE river_job SET state = 'available' WHERE id = #{id} AND state IN ('retryable', 'scheduled')")
269
- end
270
- rescue runtime_unique_violation_class
271
- runtime_execute(<<~SQL)
272
- UPDATE river_job
273
- SET state = 'discarded', finalized_at = #{runtime_time(now)},
274
- metadata = #{runtime_merge_metadata("unique_key_conflict" => "scheduler_discarded")}
275
- WHERE id = #{id}
276
- SQL
277
- end
278
-
279
- ids.length
285
+ queues = runtime_postgres? ? runtime_schedule_postgres(now, Integer(max)) : runtime_schedule_sqlite(now, Integer(max))
286
+ # Notify each changed queue, including queues with only discarded
287
+ # jobs, as Go does.
288
+ queues.uniq.each { |queue| runtime_notify("river_insert", queue: queue) }
289
+ queues.length
280
290
  end
281
291
  end
282
292
 
@@ -365,6 +375,16 @@ module River::Driver
365
375
  SQL
366
376
  end
367
377
 
378
+ # Opens a receiver independent of application transactions. Postgres uses
379
+ # one dedicated connection; SQLite keeps a cursor in its shared outbox.
380
+ def notification_listener
381
+ return NotificationListener::SQLite.new(method(:notification_query)) unless runtime_postgres?
382
+ return unless postgres_capabilities.supports_listen_notify
383
+
384
+ connection, schema = runtime_notification_connection
385
+ NotificationListener::Postgres.new(connection, schema)
386
+ end
387
+
368
388
  def queue_get(name)
369
389
  row = runtime_query_rows("SELECT #{runtime_queue_columns} FROM river_queue WHERE name = #{runtime_quote(name)}").first
370
390
  runtime_queue_from_row(row)
@@ -431,6 +451,21 @@ module River::Driver
431
451
  queue_get(name)
432
452
  end
433
453
 
454
+ # Delivery follows the caller's transaction, just like job insertion.
455
+ def request_resign
456
+ transaction { runtime_notify("river_leadership", action: "request_resign", leader_id: "") }
457
+ nil
458
+ end
459
+
460
+ private def notification_query(sql)
461
+ runtime_query_rows(sql)
462
+ end
463
+
464
+ # Smallest timestamp increment preserved by this driver, in seconds.
465
+ def time_precision
466
+ runtime_postgres? ? Rational(1, 1_000_000) : Rational(1, 1_000)
467
+ end
468
+
434
469
  private def runtime_append_error(error)
435
470
  value = error.respond_to?(:to_h) ? error.to_h : error
436
471
  if runtime_postgres?
@@ -558,7 +593,7 @@ module River::Driver
558
593
  params.map do |param|
559
594
  key = param.unique_key
560
595
  if key && !key.empty? && River::UniqueBitmask.to_states((param.unique_states || "0").to_i(2)).include?(param.state)
561
- # PostgreSQL rejects batches that upsert the same indexed row twice.
596
+ # Postgres rejects batches that upsert the same indexed row twice.
562
597
  raise ArgumentError, "unique key appears more than once in batch" if keys[key]
563
598
 
564
599
  keys[key] = true
@@ -580,6 +615,10 @@ module River::Driver
580
615
  "(#{column} IS NULL OR #{column} #{comparison} #{value} OR (#{column} = #{value} AND #{id_clause}))"
581
616
  end
582
617
 
618
+ private def runtime_job_attempt_predicate(job)
619
+ "attempt = #{Integer(job.attempt)} AND attempted_at IS NOT DISTINCT FROM #{runtime_nullable_time(job.attempted_at)}"
620
+ end
621
+
583
622
  private def runtime_job_list(params, for_delete: false)
584
623
  clauses = [] #: Array[String]
585
624
  clauses << "state != 'running'" if for_delete
@@ -594,7 +633,7 @@ module River::Driver
594
633
  finalized = params.sort_by == :finalized_at && params.states&.length == 1 &&
595
634
  %w[cancelled completed discarded].include?(params.states.first)
596
635
  # Schemas require finalized timestamps for terminal states. Spell this out
597
- # so PostgreSQL can use the partial (state, finalized_at) index.
636
+ # so Postgres can use the partial (state, finalized_at) index.
598
637
  clauses << "finalized_at IS NOT NULL" if finalized
599
638
  # Explicit NULLS LAST prevents a backward index scan, even when there are
600
639
  # no nulls. Only request it for timestamps that can actually be null.
@@ -620,18 +659,33 @@ module River::Driver
620
659
  runtime_postgres? ? "#{runtime_quote(encoded)}::jsonb" : "jsonb(#{runtime_quote(encoded)})"
621
660
  end
622
661
 
662
+ # Temporary fallback: when true, transaction helpers open a savepoint
663
+ # inside an open transaction instead of joining it.
664
+ private def runtime_legacy_subtransactions?
665
+ %w[1 true].include?(ENV.fetch("RIVER_USE_LEGACY_SUBTRANSACTIONS", ""))
666
+ end
667
+
623
668
  private def runtime_merge_metadata(metadata)
624
669
  raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
625
670
 
626
671
  if runtime_postgres?
627
672
  "metadata || #{runtime_json(metadata)}"
628
673
  else
629
- # PostgreSQL's || replaces top-level values, including JSON null.
630
- # JSON Merge Patch would recursively merge objects and delete nulls.
631
- merged = metadata.reduce("metadata") do |expression, (key, value)|
632
- path = runtime_quote("$.#{JSON.generate(key.to_s)}")
633
- "jsonb_set(#{expression}, #{path}, jsonb(#{runtime_quote(JSON.generate(value))}))"
634
- end
674
+ # Keep the SQL shape fixed regardless of the number of updated keys.
675
+ # -> preserves JSON booleans and large numeric tokens during aggregation.
676
+ merged = <<~SQL.strip
677
+ (WITH updates(doc) AS (SELECT #{runtime_json(metadata)})
678
+ SELECT CASE WHEN json_type(metadata) = 'object' THEN (
679
+ SELECT jsonb_group_object(key, jsonb(doc -> fullkey))
680
+ FROM (
681
+ SELECT key, fullkey, metadata AS doc FROM json_each(metadata)
682
+ WHERE key NOT IN (SELECT key FROM updates, json_each(doc))
683
+ UNION ALL
684
+ SELECT key, fullkey, doc FROM updates, json_each(doc)
685
+ )
686
+ ) WHEN json_type(metadata) = 'array' THEN jsonb_insert(metadata, '$[#]', doc)
687
+ ELSE jsonb_array(json(metadata), doc) END FROM updates)
688
+ SQL
635
689
  "CASE WHEN json_valid(metadata, 10) THEN #{merged} ELSE metadata END"
636
690
  end
637
691
  end
@@ -712,6 +766,114 @@ module River::Driver
712
766
  runtime_query_rows(sql).map { |row| runtime_value(row, :id).to_i }
713
767
  end
714
768
 
769
+ # Finds conflicts and changes the whole pass in one statement.
770
+ private def runtime_schedule_postgres(now, max)
771
+ now = runtime_time(now)
772
+ runtime_query_rows(<<~SQL).map { |row| runtime_value(row, :queue) }
773
+ WITH jobs_to_schedule AS (
774
+ SELECT id, unique_key, unique_states, priority, scheduled_at
775
+ FROM river_job
776
+ WHERE state IN ('retryable', 'scheduled')
777
+ AND priority >= 0
778
+ AND queue IS NOT NULL
779
+ AND scheduled_at <= #{now}
780
+ ORDER BY priority, scheduled_at, id
781
+ LIMIT #{max}
782
+ FOR UPDATE
783
+ ),
784
+ jobs_with_rownum AS (
785
+ SELECT
786
+ *,
787
+ CASE
788
+ WHEN unique_key IS NOT NULL AND unique_states IS NOT NULL THEN
789
+ ROW_NUMBER() OVER (PARTITION BY unique_key ORDER BY priority, scheduled_at, id)
790
+ END AS row_num
791
+ FROM jobs_to_schedule
792
+ ),
793
+ unique_conflicts AS (
794
+ SELECT river_job.unique_key
795
+ FROM river_job
796
+ JOIN jobs_with_rownum
797
+ ON river_job.unique_key = jobs_with_rownum.unique_key
798
+ AND river_job.id != jobs_with_rownum.id
799
+ WHERE river_job.unique_key IS NOT NULL
800
+ AND river_job.unique_states IS NOT NULL
801
+ AND river_job_state_in_bitmask(river_job.unique_states, river_job.state)
802
+ ),
803
+ job_updates AS (
804
+ SELECT
805
+ job.id,
806
+ job.row_num IS NOT NULL AND (uc.unique_key IS NOT NULL OR job.row_num > 1) AS discard
807
+ FROM jobs_with_rownum job
808
+ LEFT JOIN unique_conflicts uc ON job.unique_key = uc.unique_key
809
+ )
810
+ UPDATE river_job
811
+ SET state = CASE WHEN job_updates.discard THEN 'discarded'::river_job_state ELSE 'available'::river_job_state END,
812
+ finalized_at = CASE WHEN job_updates.discard THEN #{now} ELSE river_job.finalized_at END,
813
+ metadata = CASE WHEN job_updates.discard THEN river_job.metadata || '{"unique_key_conflict": "scheduler_discarded"}'::jsonb
814
+ ELSE river_job.metadata END
815
+ FROM job_updates
816
+ WHERE river_job.id = job_updates.id
817
+ RETURNING river_job.queue
818
+ SQL
819
+ end
820
+
821
+ # Follows Go's SQLite driver: check each unique job in order, and make it
822
+ # available before checking the next one, so a later job with the same
823
+ # key sees it as a conflict. SQLite allows one writer at a time. Once this
824
+ # pass writes, other connections cannot write until it ends. If another
825
+ # connection writes first, this pass's next write fails instead of using
826
+ # stale rows.
827
+ private def runtime_schedule_sqlite(now, max)
828
+ rows = runtime_query_rows(<<~SQL)
829
+ SELECT id, queue, unique_key IS NOT NULL AS has_unique_key
830
+ FROM river_job
831
+ WHERE state IN ('retryable', 'scheduled') AND scheduled_at <= #{runtime_time(now)}
832
+ ORDER BY priority, scheduled_at, id
833
+ LIMIT #{max}
834
+ SQL
835
+ available = [] #: Array[Integer]
836
+ discarded = [] #: Array[Integer]
837
+ rows.each do |row|
838
+ id = runtime_value(row, :id).to_i
839
+ next available << id if runtime_value(row, :has_unique_key).to_i.zero?
840
+
841
+ conflict = runtime_query_rows(<<~SQL).any?
842
+ SELECT 1 FROM river_job
843
+ WHERE id <> #{id}
844
+ AND unique_key = (SELECT job.unique_key FROM river_job AS job WHERE job.id = #{id})
845
+ AND unique_states IS NOT NULL
846
+ AND CASE state
847
+ WHEN 'available' THEN unique_states & (1 << 0)
848
+ WHEN 'cancelled' THEN unique_states & (1 << 1)
849
+ WHEN 'completed' THEN unique_states & (1 << 2)
850
+ WHEN 'discarded' THEN unique_states & (1 << 3)
851
+ WHEN 'pending' THEN unique_states & (1 << 4)
852
+ WHEN 'retryable' THEN unique_states & (1 << 5)
853
+ WHEN 'running' THEN unique_states & (1 << 6)
854
+ WHEN 'scheduled' THEN unique_states & (1 << 7)
855
+ ELSE 0
856
+ END >= 1
857
+ LIMIT 1
858
+ SQL
859
+ next discarded << id if conflict
860
+
861
+ runtime_execute("UPDATE river_job SET state = 'available' WHERE id = #{id}")
862
+ end
863
+
864
+ unless discarded.empty?
865
+ runtime_execute(<<~SQL)
866
+ UPDATE river_job
867
+ SET state = 'discarded', finalized_at = #{runtime_time(now)},
868
+ metadata = #{runtime_merge_metadata("unique_key_conflict" => "scheduler_discarded")}
869
+ WHERE id IN (#{discarded.join(",")})
870
+ SQL
871
+ end
872
+ runtime_execute("UPDATE river_job SET state = 'available' WHERE id IN (#{available.join(",")})") unless available.empty?
873
+
874
+ rows.map { |row| runtime_value(row, :queue) }
875
+ end
876
+
715
877
  private def runtime_state(value)
716
878
  runtime_postgres? ? "#{runtime_quote(value)}::river_job_state" : runtime_quote(value)
717
879
  end
@@ -729,9 +891,9 @@ module River::Driver
729
891
 
730
892
  cast = runtime_postgres? ? "::timestamptz" : ""
731
893
  encoded = if runtime_postgres?
732
- value.getutc.iso8601(6)
894
+ normalize_time(value).iso8601(6)
733
895
  else
734
- value.getutc.round(3).strftime("%Y-%m-%d %H:%M:%S.%3N")
896
+ normalize_time(value).strftime("%Y-%m-%d %H:%M:%S.%3N")
735
897
  end
736
898
  "#{runtime_quote(encoded)}#{cast}"
737
899
  end
data/lib/driver.rb CHANGED
@@ -50,4 +50,5 @@ end
50
50
 
51
51
  require_relative "driver/postgres_capabilities"
52
52
  require_relative "driver/job_row_decoder"
53
+ require_relative "driver/notification_listener"
53
54
  require_relative "driver/runtime"
data/lib/insert_opts.rb CHANGED
@@ -94,7 +94,7 @@ module River
94
94
  # be inserted as a new job.
95
95
  class UniqueOpts
96
96
  # Empty options don't enable uniqueness. Zero periods and empty state lists
97
- # have the same meaning as their unset equivalents in the Go client.
97
+ # have the same meaning as their unset equivalents.
98
98
  def empty?
99
99
  !by_args && !by_queue && !exclude_kind && (!by_period || by_period == 0) && (!by_state || by_state.empty?)
100
100
  end
data/lib/job.rb CHANGED
@@ -59,7 +59,7 @@ module River
59
59
 
60
60
  # The set of worker IDs that have worked this job. A worker ID differs
61
61
  # between different programs, but is shared by all executors within any
62
- # given one. (i.e. Different Go processes have different IDs, but IDs are
62
+ # given one. (i.e. Different processes have different IDs, but IDs are
63
63
  # shared within any given process.) A process generates a new ID based on
64
64
  # host and current time when it starts up.
65
65
  attr_accessor :attempted_by
@@ -173,7 +173,7 @@ module River
173
173
  # that occurred.
174
174
  class AttemptError
175
175
  # The time at which the error occurred. Unreadable historical timestamps
176
- # become Time.utc(1), matching Go's zero time.
176
+ # become Time.utc(1), the zero time 0001-01-01T00:00:00Z.
177
177
  attr_accessor :at
178
178
 
179
179
  # The attempt number on which the error occurred (maps to #attempt on a job
@@ -184,8 +184,8 @@ module River
184
184
  # value in case of a panic.
185
185
  attr_accessor :error
186
186
 
187
- # Contains a stack trace from a job that panicked. The trace is produced by
188
- # invoking `debug.Trace()` in Go.
187
+ # Contains a stack trace from a job that raised or panicked. Ruby records
188
+ # the exception backtrace; other River clients record their own format.
189
189
  attr_accessor :trace
190
190
 
191
191
  def initialize(
@@ -5,7 +5,8 @@ require "logger"
5
5
  module River
6
6
  module JobPersistedLogging
7
7
  # Captures job.logger output and persists it with the attempt's state change,
8
- # including failed attempts. Uses Go riverlog's metadata format for River UI.
8
+ # including failed attempts. Uses the shared `river:log` metadata format
9
+ # that River UI displays.
9
10
  # Register through Config#plugins, preferably before other work middleware.
10
11
  class Plugin
11
12
  # Limits are positive byte counts. History is capped at 64 MiB, dropping
@@ -84,7 +85,7 @@ module River
84
85
  @mutex.synchronize do
85
86
  @closed = true
86
87
  # Drop invalid/incomplete UTF-8 sequences (including a character cut
87
- # at the byte limit) and NULs, which PostgreSQL JSONB cannot represent.
88
+ # at the byte limit) and NULs, which Postgres JSONB cannot represent.
88
89
  [@data.dup.force_encoding(Encoding::UTF_8).scrub("").delete("\0"), @truncated]
89
90
  end
90
91
  end
data/lib/migrator.rb CHANGED
@@ -9,7 +9,7 @@ module River
9
9
 
10
10
  attr_reader :migrations
11
11
 
12
- # Creates a migrator for an installed River driver. PostgreSQL schemas must
12
+ # Creates a migrator for an installed River driver. Postgres schemas must
13
13
  # already exist and use simple SQL identifiers; SQLite uses its main schema.
14
14
  # The optional migrations_path is a root containing backend/line/*.sql.
15
15
  def initialize(driver, line: "main", migrations_path: File.expand_path("../migration", __dir__.to_s), schema: nil)
data/lib/periodic_cron.rb CHANGED
@@ -5,8 +5,9 @@ module River
5
5
  class PeriodicCron
6
6
  # Parses a cron expression once. +timezone+ defaults to UTC; use an IANA
7
7
  # name or a fixed offset for local schedules. A CRON_TZ= or TZ= prefix takes
8
- # precedence. Supports Go's @every durations, rounded down to whole seconds
9
- # with a one-second minimum. Fugit is loaded only on construction.
8
+ # precedence. Supports @every durations such as `1h30m` or `500ms`, rounded
9
+ # down to whole seconds with a one-second minimum. Fugit is loaded only on
10
+ # construction.
10
11
  def initialize(expression, timezone: "UTC")
11
12
  require "fugit"
12
13
 
@@ -31,7 +32,7 @@ module River
31
32
  end
32
33
 
33
34
  # Returns a UTC Time strictly after +time+. Interval schedules align to
34
- # whole seconds, as Go does. Calendar and daylight-saving rules use Fugit.
35
+ # whole seconds. Calendar and daylight-saving rules use Fugit.
35
36
  def next(time)
36
37
  interval = @interval
37
38
  return Time.at(time.to_i + interval).utc if interval
@@ -51,7 +52,7 @@ module River
51
52
  raise ArgumentError, "invalid @every duration" unless valid
52
53
  units = {"ns" => 1, "us" => 1_000, "µs" => 1_000, "μs" => 1_000, "ms" => 1_000_000, "s" => 1_000_000_000, "m" => 60_000_000_000, "h" => 3_600_000_000_000}
53
54
  nanos = parts.sum { |number, unit| (number.to_r * units.fetch(unit)).to_i } * sign
54
- raise ArgumentError, "@every duration exceeds Go's duration range" unless (-(1 << 63)...(1 << 63)).cover?(nanos)
55
+ raise ArgumentError, "@every duration must fit in signed 64-bit nanoseconds" unless (-(1 << 63)...(1 << 63)).cover?(nanos)
55
56
 
56
57
  [nanos.div(1_000_000_000), 1].max
57
58
  end
data/lib/periodic_job.rb CHANGED
@@ -76,7 +76,10 @@ module River
76
76
  raise ArgumentError, "periodic jobs require leader election" if !@enabled && !jobs.empty?
77
77
 
78
78
  now = Time.now.utc
79
- entries = jobs.map { |job| {job: job, next_at: job.run_on_start ? now : job.next_at(now)} }
79
+ entries = jobs.map do |job|
80
+ run_on_start = job.run_on_start
81
+ {job: job, next_at: run_on_start ? now : job.next_at(now), run_on_start: run_on_start}
82
+ end
80
83
  handles = @mutex.synchronize do
81
84
  ids = @jobs.values.to_h { |entry| [entry[:job].id, true] }
82
85
  jobs.each do |job|
@@ -100,7 +103,9 @@ module River
100
103
  self
101
104
  end
102
105
 
103
- def due(now)
106
+ # Internal scheduler entry point that retains each claimed occurrence's
107
+ # time even after advancing its registration.
108
+ def __due_with_times(now)
104
109
  # Application schedules may reenter the registry, so evaluate outside its
105
110
  # lock and only advance registrations that haven't been removed or claimed.
106
111
  entries = @mutex.synchronize { @jobs.map { |handle, entry| [handle, entry, entry[:next_at]] } }
@@ -110,8 +115,10 @@ module River
110
115
  following = entry[:job].next_at(now)
111
116
  @mutex.synchronize do
112
117
  next unless @jobs[handle].equal?(entry) && entry[:next_at] == next_at
118
+ scheduled_at = entry[:run_on_start] ? now : next_at
119
+ entry[:run_on_start] = false
113
120
  entry[:next_at] = following
114
- entry[:job]
121
+ [entry[:job], scheduled_at]
115
122
  end
116
123
  rescue => error
117
124
  raise unless block_given?
@@ -120,6 +127,10 @@ module River
120
127
  end
121
128
  end
122
129
 
130
+ def due(now, &block)
131
+ __due_with_times(now, &block).map(&:first)
132
+ end
133
+
123
134
  # Returns true when the bundle has no registered periodic jobs.
124
135
  def empty?
125
136
  @mutex.synchronize { @jobs.empty? }