stampeded 0.4.1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3ce5382689990aa3c0d7280ec0e86af16f18a7494a1fd96603bd6942c467c4b
4
- data.tar.gz: 9e055b934cca7b96d9a7b464235d9d6b515937dc9c8d53618e2461a12d4134f4
3
+ metadata.gz: db2468be1b5b64e89bd00f2bebccbafce4faf21c875065d91d5f43a394bdbd9c
4
+ data.tar.gz: 5f5bc056343dff4ac2c83eec8eda56334e9d1423cc22acdae97aaba046f9672d
5
5
  SHA512:
6
- metadata.gz: aaa523d7d9dd7d567bd1818da6a9c0064b8cc1582f76cb9279ce6f2b2b7c209f75e21aa36185f4e8a0faef2033f1f732d3c48846232f2576ffec25729ad271c4
7
- data.tar.gz: dbba8d9afc205878ae795aad19953daaabb080c593769c30ab54f2c363f6d06abff8e0c41d7592a9b31e0ebe12c300df86fed9caaa4d6bc7f2de47b499f039e0
6
+ metadata.gz: e5d126e89b18e370b2b9cee271d025eb2f68a7967aaaea185423854d99f314c88266a06dbb7b54ee3b6b3659319ff7a169c553bd3f61f08e144b182b9b06cb01
7
+ data.tar.gz: e77790d2458e5be336950f1590958afc7ebc52bc03be7b9c0db0a40d173ddc91b0b8c4ac092cfb8606a6d5ecd79e54aa73a42f7fee4755aabeaa3ea895789c0a
data/README.md CHANGED
@@ -135,7 +135,8 @@ Stampeded::Schedule.recurring(
135
135
 
136
136
  The daemon fires it; a new schedule first runs at its next occurrence. An
137
137
  expression that names its hours runs once on a DST fall-back day; one using `*`
138
- or a step runs on real elapsed time. `Stampeded::Schedule.pause`, `.remove` and
138
+ or a step runs on real elapsed time. The queue is `queue:`, or the job's
139
+ `queue_as` evaluated with `args`. `Stampeded::Schedule.pause`, `.remove` and
139
140
  `.all` manage them.
140
141
 
141
142
  ## Workers
@@ -164,8 +165,9 @@ config.except_queues = %w[payments_dlq] # with :all
164
165
  - `SIGTERM` lets in-flight jobs finish before exiting. Jobs still running after
165
166
  `shutdown_timeout` are reclaimed by the daemon.
166
167
  - The worker runs on `libstampeded_ffi`, the same core every runtime's worker
167
- uses; see [The binaries](#the-binaries). `STAMPEDED_LOG=1` logs each job;
168
- `STAMPEDED_LOG=debug` (or `info`, `warn`, ...) also sets the core's level.
168
+ uses; see [The binaries](#the-binaries). Failures to claim or report go to
169
+ stderr always; `STAMPEDED_LOG=1` also logs each job, and
170
+ `STAMPEDED_LOG=debug` (or `info`, `warn`, ...) sets the core's level.
169
171
 
170
172
  ## Queue policies
171
173
 
@@ -178,6 +180,14 @@ The daemon turns these into a verdict per queue every second; the worker claims
178
180
  rushed queues first and skips held ones. `max_running` also limits what one
179
181
  worker process holds from that queue, with or without the daemon.
180
182
 
183
+ ```ruby
184
+ Stampeded::QueuePolicy.pause("mail") # no worker in any language claims from it
185
+ Stampeded::QueuePolicy.resume("mail")
186
+ ```
187
+
188
+ Jobs already running finish, and `perform_later` and schedules keep queueing
189
+ behind the pause. `set` keeps a pause; `remove` lifts it.
190
+
181
191
  ## Without Rails
182
192
 
183
193
  ```ruby
@@ -21,17 +21,22 @@ module Stampeded
21
21
  end
22
22
 
23
23
  def from_active_job(job)
24
- task = job.class.try(:stampeded_task)
25
- context = job.serialize.except("arguments", "provider_job_id")
26
- kwargs = standard_arguments(job.arguments)
24
+ build(job.class, job.arguments, job.serialize.except("arguments", "provider_job_id"))
25
+ end
26
+
27
+ # [task, args, meta] for `arguments` as `perform` takes them: plain keywords
28
+ # become portable `args`, anything else rides in ActiveJob's own format.
29
+ def build(job_class, arguments, context)
30
+ task = job_class.try(:stampeded_task)
31
+ kwargs = standard_arguments(arguments)
27
32
 
28
33
  if kwargs
29
- [task || job.class.name, kwargs, { "activejob" => context }]
34
+ [task || job_class.name, kwargs, { "activejob" => context }]
30
35
  elsif task
31
- raise PortableArgumentsError.new(job.class.name, task)
36
+ raise PortableArgumentsError.new(job_class.name, task)
32
37
  else
33
- context["arguments"] = ActiveJob::Arguments.serialize(job.arguments)
34
- [job.class.name, {}, { "activejob" => context }]
38
+ context["arguments"] = ActiveJob::Arguments.serialize(arguments)
39
+ [job_class.name, {}, { "activejob" => context }]
35
40
  end
36
41
  end
37
42
 
@@ -18,7 +18,8 @@ module Stampeded
18
18
 
19
19
  def events_from(state) = events.select { legal?(state, it) }
20
20
 
21
- def terminal?(state) = events_from(state).empty?
21
+ # No worker moves it again; only an operator's resurrect does.
22
+ def terminal?(state) = events_from(state).all?(:resurrect)
22
23
 
23
24
  def claimable?(state) = legal?(state, :claim)
24
25
 
@@ -21,6 +21,34 @@ module Stampeded
21
21
  queue.to_s
22
22
  end
23
23
 
24
+ # No worker, in any runtime, claims from a paused queue; running jobs finish
25
+ # and enqueueing carries on. `set` keeps a pause; `remove` lifts it.
26
+ def pause(queue)
27
+ raise Error, "a queue policy needs a queue name" if queue.to_s.strip.empty?
28
+
29
+ Connection.translating_errors do
30
+ Connection.with do |c|
31
+ c.exec_params(<<~SQL, [queue.to_s])
32
+ INSERT INTO stampeded_queue_policies (queue, paused) VALUES ($1, true)
33
+ ON CONFLICT (queue) DO UPDATE SET paused = true, updated_at = now()
34
+ SQL
35
+ end
36
+ end
37
+ queue.to_s
38
+ end
39
+
40
+ def resume(queue)
41
+ Connection.translating_errors do
42
+ Connection.with do |c|
43
+ c.exec_params(<<~SQL, [queue.to_s])
44
+ UPDATE stampeded_queue_policies SET paused = false, updated_at = now()
45
+ WHERE queue = $1 AND paused
46
+ SQL
47
+ end
48
+ end
49
+ queue.to_s
50
+ end
51
+
24
52
  def remove(queue)
25
53
  Connection.with { |c| c.exec_params("DELETE FROM stampeded_queue_policies WHERE queue = $1", [queue.to_s]) }
26
54
  end
@@ -29,7 +57,7 @@ module Stampeded
29
57
  Connection.translating_errors do
30
58
  Connection.with do |c|
31
59
  c.exec(<<~SQL).to_a
32
- SELECT queue, max_running, max_wait_secs, verdict, reason, decided_at
60
+ SELECT queue, max_running, max_wait_secs, paused, verdict, reason, decided_at
33
61
  FROM stampeded_queue_policies ORDER BY queue
34
62
  SQL
35
63
  end
@@ -13,9 +13,11 @@ module Stampeded
13
13
  suggested = check_cron!(cron, timezone)
14
14
  dst_policy ||= suggested
15
15
  job_class = job.is_a?(Class) ? job : job.class
16
- queue ||= resolve_queue_name(job_class) || Stampeded.config.default_queue
16
+ # As `perform` takes them: a Hash is keywords, anything else positional.
17
+ arguments = args.is_a?(Hash) ? [Hash.ruby2_keywords_hash(args)] : Array(args)
18
+ queue ||= resolve_queue_name(job_class, arguments) || Stampeded.config.default_queue
17
19
 
18
- task, standard_args, meta = envelope(job_class, args, queue, priority)
20
+ task, standard_args, meta = envelope(job_class, arguments, queue, priority)
19
21
 
20
22
  Connection.with do |c|
21
23
  c.exec_params(<<~SQL, [key, queue, task, JSON.generate(standard_args), JSON.generate(meta), cron, timezone, dst_policy, priority])
@@ -68,18 +70,19 @@ module Stampeded
68
70
  MSG
69
71
  end
70
72
 
71
- def resolve_queue_name(job_class)
73
+ # A `queue_as` block sees the schedule's arguments, which are the same every run.
74
+ def resolve_queue_name(job_class, arguments)
72
75
  return nil unless job_class.respond_to?(:queue_name)
73
76
 
74
77
  name = job_class.queue_name
75
- name = job_class.new.queue_name if name.respond_to?(:call)
78
+ name = job_class.new(*arguments).queue_name if name.respond_to?(:call)
76
79
  name&.to_s
77
- rescue StandardError
78
- nil
80
+ rescue StandardError => e
81
+ raise Error, "#{job_class.name}'s queue_as raised #{e.class}: #{e.message}; " \
82
+ "pass queue: to name the schedule's queue"
79
83
  end
80
84
 
81
- def envelope(job_class, args, queue, priority)
82
- task = job_class.try(:stampeded_task)
85
+ def envelope(job_class, arguments, queue, priority)
83
86
  context = {
84
87
  "job_class" => job_class.name,
85
88
  "queue_name" => queue,
@@ -88,15 +91,7 @@ module Stampeded
88
91
  "timezone" => defined?(Time.zone) && Time.zone ? Time.zone.name : "UTC"
89
92
  }
90
93
 
91
- kwargs = args.is_a?(Hash) ? args : ({} if Array(args).empty?)
92
- if kwargs && Envelope.plain?(kwargs)
93
- [task || job_class.name, JSON.parse(JSON.generate(kwargs)), { "activejob" => context }]
94
- elsif task
95
- raise PortableArgumentsError.new(job_class.name, task)
96
- else
97
- context["arguments"] = ActiveJob::Arguments.serialize(Array(args))
98
- [job_class.name, {}, { "activejob" => context }]
99
- end
94
+ Envelope.build(job_class, arguments, context)
100
95
  end
101
96
  end
102
97
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stampeded
4
- VERSION = "0.4.1" # x-release-please-version
4
+ VERSION = "0.5.0" # x-release-please-version
5
5
  end
@@ -17,6 +17,8 @@ module Stampeded
17
17
  @batch_size = positive!(batch_size || Stampeded.config.batch_size, "batch_size")
18
18
  @poll_interval = poll_interval || Stampeded.config.poll_interval
19
19
  @handlers = {}
20
+ @warned = {}
21
+ @warned_lock = Mutex.new
20
22
  @stopping = false
21
23
  @wake_reader, @wake_writer = IO.pipe
22
24
  end
@@ -57,8 +59,8 @@ module Stampeded
57
59
 
58
60
  stragglers = @threads.count(&:alive?)
59
61
  if stragglers.positive?
60
- log "#{stragglers} job(s) did not finish in #{Stampeded.config.shutdown_timeout}s; " \
61
- "the reaper will reclaim them"
62
+ problem "#{stragglers} job(s) did not finish in #{Stampeded.config.shutdown_timeout}s; " \
63
+ "the reaper will reclaim them"
62
64
  end
63
65
  log "stopped"
64
66
  ensure
@@ -66,7 +68,7 @@ module Stampeded
66
68
  begin
67
69
  puller.stop(0) if puller && !stopped
68
70
  rescue Error => e
69
- log "could not stop: #{e.message}"
71
+ problem "could not stop: #{e.message}"
70
72
  end
71
73
  @threads&.each { |t| t.join(@poll_interval * 2) }
72
74
  puller&.close if @threads.nil? || @threads.none?(&:alive?)
@@ -105,7 +107,7 @@ module Stampeded
105
107
  begin
106
108
  puller.next(@poll_interval)
107
109
  rescue Error => e
108
- log "claim failed: #{e.message}"
110
+ problem "claim failed: #{e.message}"
109
111
  sleep @poll_interval
110
112
  next
111
113
  end
@@ -146,7 +148,7 @@ module Stampeded
146
148
  else lease.fail!(message, retry_in_ms: error.is_a?(RetryIn) ? error.delay_ms : nil)
147
149
  end
148
150
  rescue Error => e
149
- log "could not report #{lease.row["id"]}: #{e.message}"
151
+ problem "could not report #{lease.row["id"]}: #{e.message}"
150
152
  end
151
153
 
152
154
  def with_job_timeout(job, &block)
@@ -155,7 +157,9 @@ module Stampeded
155
157
 
156
158
  expire = (job.row["expire_seconds"] || 3600).to_i
157
159
  if limit >= expire
158
- log "job_timeout (#{limit}s) is not shorter than #{job.task}'s expire_seconds (#{expire}s): it may run twice"
160
+ once([job.task, expire]) do
161
+ problem "job_timeout (#{limit}s) is not shorter than #{job.task}'s expire_seconds (#{expire}s): it may run twice"
162
+ end
159
163
  end
160
164
 
161
165
  Timeout.timeout(limit, Timeout::Error, "job exceeded job_timeout of #{limit}s", &block)
@@ -215,10 +219,19 @@ module Stampeded
215
219
  log? ? "info" : "warn"
216
220
  end
217
221
 
222
+ # Progress: each job, start and stop. Only when asked for, or on a terminal.
218
223
  def log(message)
219
224
  return unless log?
220
225
 
221
226
  warn "[stampeded] #{message}"
222
227
  end
228
+
229
+ # Something an operator must see, which a service manager's stdout is not.
230
+ def problem(message) = warn("[stampeded] #{message}")
231
+
232
+ def once(key)
233
+ first = @warned_lock.synchronize { @warned[key] ? false : (@warned[key] = true) }
234
+ yield if first
235
+ end
223
236
  end
224
237
  end
@@ -0,0 +1,58 @@
1
+ -- Schema 6: a wall-clock occurrence of a schedule whose zone Postgres does not
2
+ -- know is keyed in UTC, as the scheduler fires it. Before, the key raised, and
3
+ -- the error rolled back the whole tick: one mistyped zone stopped every schedule.
4
+ CREATE OR REPLACE FUNCTION stampeded_fire_schedule(p_key text, p_next_run_at timestamptz)
5
+ RETURNS uuid
6
+ LANGUAGE plpgsql AS $$
7
+ DECLARE
8
+ s stampeded_schedules%ROWTYPE;
9
+ v_token text;
10
+ v_job uuid;
11
+ -- Separate from v_job: the ledger's id is bigserial, the job's is uuid.
12
+ -- Reusing one variable for both is a runtime cast error, not a compile one.
13
+ v_occ bigint;
14
+ BEGIN
15
+ SELECT * INTO s FROM stampeded_schedules WHERE key = p_key;
16
+ IF NOT FOUND THEN
17
+ RETURN NULL;
18
+ END IF;
19
+
20
+ IF s.dst_policy = 'wall_clock' THEN
21
+ BEGIN
22
+ v_token := to_char(s.next_run_at AT TIME ZONE s.timezone, 'YYYY-MM-DD HH24:MI');
23
+ EXCEPTION WHEN invalid_parameter_value THEN
24
+ v_token := to_char(s.next_run_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI');
25
+ END;
26
+ ELSE
27
+ v_token := extract(epoch FROM s.next_run_at)::bigint::text;
28
+ END IF;
29
+
30
+ INSERT INTO stampeded_cron_occurrences (cron_key, occurrence_token)
31
+ VALUES (s.key, v_token)
32
+ ON CONFLICT (cron_key, occurrence_token) DO NOTHING
33
+ RETURNING id INTO v_occ;
34
+
35
+ IF v_occ IS NULL THEN
36
+ UPDATE stampeded_schedules
37
+ SET next_run_at = p_next_run_at, updated_at = now()
38
+ WHERE key = s.key;
39
+ RETURN NULL;
40
+ END IF;
41
+
42
+ INSERT INTO stampeded_jobs (queue, task, args, meta, tenant_id, priority)
43
+ VALUES (s.queue, s.task, s.args, s.meta, s.tenant_id, s.priority)
44
+ RETURNING id INTO v_job;
45
+
46
+ UPDATE stampeded_cron_occurrences
47
+ SET job_id = v_job
48
+ WHERE cron_key = s.key AND occurrence_token = v_token;
49
+
50
+ UPDATE stampeded_schedules
51
+ SET next_run_at = p_next_run_at, last_run_at = now(), updated_at = now()
52
+ WHERE key = s.key;
53
+
54
+ RETURN v_job;
55
+ END;
56
+ $$;
57
+
58
+ UPDATE stampeded_schema SET version = 6, changed_at = now();
@@ -0,0 +1,83 @@
1
+ -- Schema 7: a queue can be paused. A paused queue's jobs stay where they are
2
+ -- and enqueueing still works; stampeded_claim hands out nothing from it, so
3
+ -- the pause holds for every runtime and without a daemon. Jobs already
4
+ -- running finish.
5
+ ALTER TABLE stampeded_queue_policies
6
+ ADD COLUMN IF NOT EXISTS paused boolean NOT NULL DEFAULT false;
7
+
8
+ -- Whether p_queue is paused; false, not an error, without the policies table,
9
+ -- which workers already treat as optional. plpgsql so the table is looked up
10
+ -- only once it is known to exist.
11
+ CREATE OR REPLACE FUNCTION stampeded_queue_paused(p_queue text)
12
+ RETURNS boolean
13
+ LANGUAGE plpgsql STABLE AS $$
14
+ BEGIN
15
+ IF to_regclass('stampeded_queue_policies') IS NULL THEN
16
+ RETURN false;
17
+ END IF;
18
+ RETURN EXISTS (SELECT 1 FROM stampeded_queue_policies
19
+ WHERE queue = p_queue AND paused);
20
+ END;
21
+ $$;
22
+
23
+ CREATE OR REPLACE FUNCTION stampeded_claim(
24
+ p_queue text,
25
+ p_worker_id uuid,
26
+ p_limit integer DEFAULT 1,
27
+
28
+ p_now timestamptz DEFAULT NULL
29
+ ) RETURNS SETOF stampeded_jobs
30
+ LANGUAGE sql AS $$
31
+ WITH candidate AS MATERIALIZED (
32
+ SELECT id
33
+ FROM stampeded_jobs
34
+ WHERE queue = p_queue
35
+ -- 'retryable' is claimable once its backoff has elapsed. That can be
36
+ -- handled by a claim predicate, a separate promotion pass, or both.
37
+ -- The predicate is chosen here for having no moving parts -- a
38
+ -- promotion pass is another process that can fall behind or die.
39
+ --
40
+ -- Omitting 'retryable' here is not a missing feature, it is a silent
41
+ -- poison-job bug: the reaper parks a crashed job in 'retryable', a
42
+ -- claim that only reads 'available' never picks it up again, the
43
+ -- attempt budget never walks down, and max_attempts is never
44
+ -- reached. Found by test, not by reading.
45
+ AND state IN ('available','retryable')
46
+ AND scheduled_at <= coalesce(p_now, now())
47
+ AND attempt < max_attempts
48
+ -- Reads no job column, so it is a one-time filter: a paused queue
49
+ -- skips the index scan rather than filtering every row it reads.
50
+ AND NOT stampeded_queue_paused(p_queue)
51
+ -- Must match stampeded_jobs_claim_idx EXACTLY. `id` is not a
52
+ -- tiebreaker here: adding one reintroduces a sort unless it is also
53
+ -- added to the index, and in the index it costs 8.5x the size by
54
+ -- defeating btree deduplication. See the claim index note in NOTES.md.
55
+ ORDER BY priority, scheduled_at
56
+ LIMIT p_limit
57
+ FOR UPDATE SKIP LOCKED
58
+ )
59
+ UPDATE stampeded_jobs j
60
+ SET state = 'executing',
61
+ attempt = j.attempt + 1,
62
+ -- Seed the job heartbeat at claim so stampeded_expire has a baseline
63
+ -- for a job that never calls stampeded_touch. Left NULL it would fall
64
+ -- back to claimed_at, which is the same thing -- but only by accident,
65
+ -- and the expiry predicate should not depend on that coincidence.
66
+ heartbeat_at = coalesce(p_now, now()),
67
+ claimed_by = p_worker_id,
68
+ claimed_at = coalesce(p_now, now()),
69
+ -- Bounded ring buffer. Unbounded attempt history is a slow leak on
70
+ -- heavily-retried jobs; keep the last 10.
71
+ attempted_by = array_append(
72
+ CASE WHEN coalesce(array_length(j.attempted_by, 1), 0) >= 10
73
+ THEN j.attempted_by[coalesce(array_length(j.attempted_by,1),0) - 8 :]
74
+ ELSE j.attempted_by
75
+ END,
76
+ p_worker_id::text)
77
+ FROM candidate c
78
+ WHERE j.queue = p_queue
79
+ AND j.id = c.id
80
+ RETURNING j.*;
81
+ $$;
82
+
83
+ UPDATE stampeded_schema SET version = 7, changed_at = now();
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stampeded
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -86,6 +86,8 @@ files:
86
86
  - schema/0003_dedup_key_separator.sql
87
87
  - schema/0004_dedup_key_raw_scope.sql
88
88
  - schema/0005_schema_version_guard.sql
89
+ - schema/0006_schedule_unknown_timezone.sql
90
+ - schema/0007_queue_pause.sql
89
91
  homepage: https://github.com/seuros/stampeded
90
92
  licenses:
91
93
  - BSD-3-Clause