async-background 0.7.2 → 1.0.1

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.
data/README.md CHANGED
@@ -1,36 +1,67 @@
1
1
  # Async::Background
2
2
 
3
- A lightweight cron, interval, and job-queue scheduler for Ruby's [Async](https://github.com/socketry/async) ecosystem. Built for [Falcon](https://github.com/socketry/falcon), works with any Async app.
3
+ A lightweight cron, interval, and job-queue scheduler for Ruby's
4
+ [Async](https://github.com/socketry/async) ecosystem. Built for
5
+ [Falcon](https://github.com/socketry/falcon), works with any Async app.
6
+
7
+ - **Cron & interval scheduling** on a single event loop with a min-heap.
8
+ - **Dynamic job queue** backed by SQLite, with delayed jobs
9
+ (`perform_in` / `perform_at`).
10
+ - **Cross-process wake-ups** over Unix domain sockets — web workers can
11
+ enqueue and instantly wake background workers.
12
+ - **Multi-process safe** — deterministic worker sharding, no duplicate
13
+ execution.
14
+ - **Per-job timeouts**, skip-on-overlap, startup jitter, optional metrics.
4
15
 
5
- - **Cron & interval scheduling** on a single event loop with a min-heap
6
- - **Dynamic job queue** backed by SQLite, with delayed jobs (`perform_in` / `perform_at`)
7
- - **Cross-process wake-ups** over Unix domain sockets — web workers can enqueue and instantly wake background workers
8
- - **Multi-process safe** — deterministic worker sharding, no duplicate execution
9
- - **Per-job timeouts**, skip-on-overlap, startup jitter, optional metrics
16
+ ---
17
+
18
+ ## Why Async? Why fibers?
19
+
20
+ The whole gem is built around the assumption that Falcon's reactor schedules
21
+ many fibers on top of one OS thread per process — so the dashboard's SSE
22
+ stream, the cron scheduler, and the queue worker all share that one thread
23
+ cooperatively. A blocked fiber yields; a blocked thread doesn't.
24
+
25
+ ![Threads vs fibers under different Ruby web servers](docs/fibers-vs-threads.svg)
26
+
27
+ That's also why the dashboard (since 1.0.1) runs its SSE loop entirely inside
28
+ the request fiber: zero extra threads, zero `ConditionVariable`, ~4 KB per
29
+ open tab.
30
+
31
+ ---
10
32
 
11
33
  ## Requirements
12
34
 
13
- - Ruby >= 3.3
14
- - `async ~> 2.0`, `fugit ~> 1.0`
15
- - `sqlite3 ~> 2.0` (optional, for the job queue)
16
- - `async-utilization >= 0.3, < 0.5` (optional, for metrics)
35
+ | Dependency | Version | Required? |
36
+ | -------------------- | ---------------- | -------------------- |
37
+ | Ruby | `>= 3.3` | yes |
38
+ | `async` | `~> 2.0` | yes |
39
+ | `fugit` | `~> 1.0` | yes |
40
+ | `sqlite3` | `~> 2.0` | for the queue & dashboard |
41
+ | `async-utilization` | `>= 0.3, < 0.5` | for metrics |
42
+
43
+ ---
17
44
 
18
45
  ## Install
19
46
 
20
47
  ```ruby
21
48
  # Gemfile
22
49
  gem "async-background"
23
- gem "sqlite3", "~> 2.0" # optional
24
- gem "async-utilization", ">= 0.3", "< 0.5" # optional
50
+
51
+ gem "sqlite3", "~> 2.0" # if you use the queue or dashboard
52
+ gem "async-utilization", ">= 0.3", "< 0.5" # if you want worker metrics
25
53
  ```
26
54
 
27
- ## ➡️ [Get Started](docs/GET_STARTED.md)
55
+ ---
56
+
57
+ ## ➡️ [Get started](docs/GET_STARTED.md)
28
58
 
29
- Full setup walkthrough: schedule config, Falcon integration, Docker, queue, delayed jobs.
59
+ A four-step walkthrough: schedule config, Falcon integration, Docker, queue,
60
+ delayed jobs.
30
61
 
31
62
  ---
32
63
 
33
- ## Quick Look
64
+ ## Quick look
34
65
 
35
66
  ```ruby
36
67
  class SendEmailJob
@@ -59,108 +90,71 @@ daily_report:
59
90
  timeout: 120
60
91
  ```
61
92
 
62
- | Key | Description |
63
- |---|---|
64
- | `class` | Job class — must include `Async::Background::Job` |
65
- | `every` / `cron` | One of: interval in seconds, or cron expression |
66
- | `timeout` | Max execution time in seconds (default: 30) |
67
- | `worker` | Pin to a specific worker. Default: `crc32(name) % total_workers` |
93
+ | Key | Description |
94
+ | ---------------- | --------------------------------------------------------------- |
95
+ | `class` | Job class — must include `Async::Background::Job`. |
96
+ | `every` / `cron` | Interval in seconds, or a cron expression. Exactly one. |
97
+ | `timeout` | Max execution time in seconds. Default: 30. |
98
+ | `worker` | Pin to a specific worker. Default: `crc32(name) % total_workers`. |
68
99
 
69
100
  ---
70
101
 
71
102
  ## Gotchas
72
103
 
73
- ### Docker: SQLite requires a named volume
74
-
75
- The SQLite database **must not** live on Docker's `overlay2` filesystem. The `overlay2` driver breaks coherence between `write()` and `mmap()`, which corrupts SQLite WAL under concurrent access.
76
-
77
- ```yaml
78
- # docker-compose.yml
79
- services:
80
- app:
81
- volumes:
82
- - queue-data:/app/tmp/queue # ← named volume, NOT overlay2
83
-
84
- volumes:
85
- queue-data:
86
- ```
87
-
88
- Without this, you will get database crashes in multi-process mode. See [Get Started → Step 3](docs/GET_STARTED.md#step-3-docker) for details. If you can't use a named volume, pass `queue_mmap: false` to disable mmap entirely.
89
-
90
- ### Other gotchas
91
-
92
- **Don't share SQLite connections across `fork()`.** The gem opens connections lazily after fork, but if you create a `Queue::Store` manually for schema setup, close it before forking:
104
+ **Docker + SQLite use a named volume.**
105
+ SQLite's database must not live on Docker's default `overlay2` filesystem:
106
+ `overlay2` breaks coherence between `write()` and `mmap()`, which corrupts
107
+ the WAL under concurrent access. Mount the queue directory as a named volume,
108
+ or pass `mmap: false` to `Store.new`. See
109
+ [Get Started → Docker](docs/GET_STARTED.md#step-3--docker-setup).
93
110
 
94
- ```ruby
95
- Async::Background::Queue.migrate!(path: db_path) # once, before fork
96
- # Every process opens its own Store lazily after fork.
97
- ```
111
+ **Don't share SQLite connections across `fork()`.**
112
+ The gem opens connections lazily after fork. If you build a `Store` manually
113
+ for schema setup, close it before forking.
98
114
 
99
- **Two clocks, on purpose.** Interval jobs use `CLOCK_MONOTONIC` (immune to NTP drift). Cron jobs use wall-clock time, because "every day at 3am" needs to mean 3am.
115
+ **Two clocks, on purpose.**
116
+ Interval jobs use `CLOCK_MONOTONIC` so NTP drift can't fire them twice. Cron
117
+ jobs use wall-clock time, because "every day at 3am" needs to mean 3am.
100
118
 
101
119
  ---
102
120
 
103
121
  ## How it works
104
122
 
123
+ A single Async task sleeps until the next entry is due, then dispatches it
124
+ under a semaphore that caps concurrency. Overlapping ticks are skipped and
125
+ rescheduled.
126
+
105
127
  ```
106
- schedule.yml ─► build_heap ─► MinHeap<Entry> ─► scheduler loop ─► Semaphore ─► run_job
128
+ schedule.yml build_heap MinHeap<Entry> scheduler loop Semaphore run_job
107
129
  ```
108
130
 
109
- A single Async task sleeps until the next entry is due, then dispatches it under a semaphore that caps concurrency. Overlapping ticks are skipped and rescheduled.
110
-
111
131
  The dynamic queue runs alongside it:
112
132
 
113
133
  ```
114
- Producer (web/console) Consumer (background worker)
115
-
116
-
117
- Queue::Client Queue::Store#fetch
118
- push / push_in / push_at (run_at <= now)
119
-
120
-
121
- Queue::Store ──── SQLite (jobs) ──── SocketWaker
122
-
123
- └───────► SocketNotifier ───────────────┘
124
- (UNIX socket wake-up, ~80µs)
125
- ```
126
-
127
- Jobs are persisted in SQLite, so a missed wake-up is never a lost job — workers also poll every 5 seconds as a safety net.
128
-
129
- ### Schema migration during deploy
130
-
131
- Run queue migrations once in the release/pre-deploy step, before starting new web or worker
132
- processes. This serializes the schema upgrade with `BEGIN IMMEDIATE`, records the version in
133
- SQLite, and avoids a first producer doing DDL under live queue traffic:
134
-
135
- ```ruby
136
- Async::Background::Queue.migrate!(path: ENV.fetch("QUEUE_DB_PATH"))
134
+ Producer (web / console) Consumer (background worker)
135
+
136
+
137
+ Queue::Client Queue::Store#fetch
138
+ push / push_in / push_at (run_at <= now)
139
+
140
+
141
+ Queue::Store ──── SQLite (jobs) ──── SocketWaker
142
+
143
+ └─────────► SocketNotifier ────────────────┘
144
+ (UNIX socket wake-up, ~80µs)
137
145
  ```
138
146
 
139
- A fresh database still self-initializes on first use for local development, but explicit
140
- migration is the production path. For an existing queue, finish or stop 0.7.1 producers/workers,
141
- run the migration once, then start 0.7.2 processes.
147
+ Jobs are persisted in SQLite, so a missed wake-up is never a lost job —
148
+ workers also poll every 5 seconds as a safety net.
142
149
 
143
- ### Future dashboard indexes
144
-
145
- The queue does **not** install dashboard indexes by default. They slow every enqueue even though
146
- pending rows never enter terminal or in-flight read-model indexes. When the 1.0 dashboard module
147
- is enabled, its installer will run this once in the same release step:
148
-
149
- ```ruby
150
- Async::Background::Queue.prepare_dashboard!(path: ENV.fetch("QUEUE_DB_PATH"))
151
- ```
152
-
153
- It adds three compact indexes: one each for cursor-sorted done and failed jobs, plus one for
154
- the bounded in-flight list. It does not change queue behavior or rerun the core migration.
150
+ ---
155
151
 
156
152
  ## Metrics
157
153
 
158
- Metrics are an optional integration with `async-utilization` (`>= 0.3`, `< 0.5`). The
159
- background worker remains fully functional when that gem is absent. With it installed, each
160
- worker publishes counters to a shared-memory segment.
154
+ Metrics are an optional integration with `async-utilization`. With the gem
155
+ installed, each worker publishes counters to a shared-memory segment:
161
156
 
162
157
  ```ruby
163
- runner.metrics.enabled?
164
158
  runner.metrics.values
165
159
  # => { total_runs: 142, total_successes: 140, total_failures: 2,
166
160
  # total_timeouts: 0, total_skips: 5, active_jobs: 1, ... }
@@ -169,19 +163,13 @@ Async::Background::Metrics.read_all(total_workers: 2)
169
163
  # => [{ worker: 1, ... }, { worker: 2, ... }]
170
164
  ```
171
165
 
172
- `Metrics.read_all` returns `[]` until the optional gem is installed and a worker has created
173
- the file, so an observer can render an unavailable state without rescuing `LoadError`. Its
174
- snapshot is lock-free best effort: cumulative fields (`total_runs`, `total_successes`,
175
- `total_failures`, `total_timeouts`, `total_skips`) are counters; `active_jobs`,
176
- `last_run_at`, and `last_duration_ms` are gauges. Fields can describe adjacent moments in time
177
- rather than one globally atomic instant.
178
-
179
- By default the file is `/tmp/async-background.shm`. Set `ASYNC_BACKGROUND_METRICS_PATH`
180
- or pass `metrics_shm_path:` to `Runner.new` when another observer runs in a separate process
181
- or container; both sides must see the same mounted file.
182
-
166
+ Without the gem, `runner.metrics.enabled?` is `false` and `read_all` returns
167
+ `[]` no `LoadError` to rescue. Configuration and the cross-container
168
+ shared-memory path are covered in
169
+ [Get Started Optional metrics](docs/GET_STARTED.md#appendix-optional-metrics).
183
170
 
171
+ ---
184
172
 
185
173
  ## License
186
174
 
187
- MIT
175
+ MIT.
@@ -33,12 +33,14 @@ Gem::Specification.new do |spec|
33
33
  spec.add_dependency 'async', '~> 2.0'
34
34
  spec.add_dependency 'console', '~> 1.0'
35
35
  spec.add_dependency 'fugit', '~> 1.0'
36
+ spec.add_dependency 'base64', '~> 0.2'
36
37
 
37
38
  # Optional: add to your own Gemfile if you need these features
38
- # gem 'sqlite3', '~> 2.0' # dynamic job queue
39
+ # gem 'sqlite3', '~> 2.0'
39
40
  # gem 'async-utilization', '>= 0.3', '< 0.5' # shared-memory worker metrics
40
41
 
41
42
  spec.add_development_dependency 'rake', '~> 13.0'
42
43
  spec.add_development_dependency 'rspec', '~> 3.12'
44
+ spec.add_development_dependency 'rack', '~> 3.0'
43
45
  spec.add_development_dependency 'async-utilization', '>= 0.3', '< 0.5'
44
46
  end
@@ -191,10 +191,12 @@ module Async
191
191
 
192
192
  def ensure_shm!(total_workers, path)
193
193
  required_size = self.class.segment_size * total_workers
194
+ page_size = IO::Buffer::PAGE_SIZE
195
+ mapped_size = ((required_size + page_size - 1) / page_size) * page_size
194
196
 
195
197
  File.open(path, File::CREAT | File::RDWR, 0o644) do |file|
196
198
  file.flock(File::LOCK_EX)
197
- file.truncate(required_size) if file.size < required_size
199
+ file.truncate(mapped_size) if file.size < mapped_size
198
200
  ensure
199
201
  file.flock(File::LOCK_UN) rescue nil
200
202
  end
@@ -13,7 +13,12 @@ module Async
13
13
  VERSION = 1
14
14
  MIGRATION_BUSY_TIMEOUT_MS = 30_000
15
15
  CORE_INDEXES = %w[idx_jobs_pending].freeze
16
- DASHBOARD_INDEXES = %w[idx_jobs_done_finished_at idx_jobs_failed_finished_at idx_jobs_running].freeze
16
+ DASHBOARD_INDEXES = %w[
17
+ idx_jobs_done_finished_at
18
+ idx_jobs_failed_finished_at
19
+ idx_jobs_executing_started_at
20
+ idx_jobs_claimed_locked_at
21
+ ].freeze
17
22
  REQUIRED_INDEXES = CORE_INDEXES
18
23
 
19
24
  module_function
@@ -192,13 +192,24 @@ module Async
192
192
  WHERE status = 'failed'
193
193
  SQL
194
194
 
195
- CREATE_RUNNING_INDEX = <<~SQL.freeze
196
- CREATE INDEX IF NOT EXISTS idx_jobs_running
195
+ CREATE_EXECUTING_INDEX = <<~SQL.freeze
196
+ CREATE INDEX IF NOT EXISTS idx_jobs_executing_started_at
197
+ ON jobs(started_at)
198
+ WHERE status = 'running' AND started_at IS NOT NULL
199
+ SQL
200
+
201
+ CREATE_CLAIMED_INDEX = <<~SQL.freeze
202
+ CREATE INDEX IF NOT EXISTS idx_jobs_claimed_locked_at
197
203
  ON jobs(locked_at)
198
- WHERE status = 'running'
204
+ WHERE status = 'running' AND started_at IS NULL
199
205
  SQL
200
206
 
201
- CREATE_DASHBOARD_INDEXES = [CREATE_DONE_INDEX, CREATE_FAILED_INDEX, CREATE_RUNNING_INDEX].freeze
207
+ CREATE_DASHBOARD_INDEXES = [
208
+ CREATE_DONE_INDEX,
209
+ CREATE_FAILED_INDEX,
210
+ CREATE_EXECUTING_INDEX,
211
+ CREATE_CLAIMED_INDEX
212
+ ].freeze
202
213
  end
203
214
  end
204
215
  end
@@ -13,6 +13,8 @@ module Async
13
13
  private
14
14
 
15
15
  def build_heap(config_path)
16
+ return MinHeap.new if config_path.nil?
17
+
16
18
  schedule = load_schedule(config_path)
17
19
  build_entries(schedule, monotonic_now)
18
20
  end
@@ -29,8 +29,11 @@ module Async
29
29
  :metrics,
30
30
  :queue_store
31
31
 
32
+ # `config_path: nil` explicitly disables recurring jobs. This keeps the
33
+ # dynamic SQLite queue usable on its own; a supplied path remains strict
34
+ # so a typo cannot silently disable scheduled work.
32
35
  def initialize(
33
- config_path:,
36
+ config_path: nil,
34
37
  job_count: 2,
35
38
  worker_index:,
36
39
  total_workers:,
@@ -53,8 +56,9 @@ module Async
53
56
 
54
57
  @drain_barrier = ::Async::Barrier.new
55
58
  @semaphore = ::Async::Semaphore.new(job_count, parent: @drain_barrier)
56
- @heap = build_heap(config_path)
59
+ @heap = config_path.nil? ? MinHeap.new : build_heap(config_path)
57
60
  setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
61
+ validate_work_source!(config_path)
58
62
  end
59
63
 
60
64
  def run
@@ -82,6 +86,11 @@ module Async
82
86
  private
83
87
 
84
88
  def scheduler_loop(task)
89
+ # Queue-only workers have no heap entry to sleep on. Keep the runner
90
+ # alive until #stop / SIGTERM wakes this condition; the queue listener
91
+ # continues independently in its own Async task.
92
+ return shutdown.wait if heap.empty? && @listen_queue
93
+
85
94
  loop do
86
95
  entry = heap.peek
87
96
  break unless entry
@@ -93,6 +102,12 @@ module Async
93
102
  end
94
103
  end
95
104
 
105
+ def validate_work_source!(config_path)
106
+ return unless config_path.nil? && !@listen_queue
107
+
108
+ raise ConfigError, 'Runner requires config_path or queue_socket_dir'
109
+ end
110
+
96
111
  def wait_for_next_entry(task, entry)
97
112
  wait = [entry.next_run_at - monotonic_now, MIN_SLEEP_TIME].max
98
113
  wait_with_shutdown(task, wait)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '0.7.2'
5
+ VERSION = '1.0.1'
6
6
  end
7
7
  end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Web
6
+ class App
7
+ def initialize(config)
8
+ @config = config.validate!
9
+ @logger = @config.logger
10
+ @auth = Auth.new(@config.auth, logger: @logger)
11
+ @snapshot = Snapshot.new(path: @config.queue_path, counts_cache_ttl: @config.counts_cache_ttl).open!
12
+ @metrics_reader = build_metrics_reader
13
+ @serializer = Serializer.new(@config)
14
+ @event_hub = build_event_hub
15
+ @router = Router.new
16
+ end
17
+
18
+ def call(env)
19
+ head = env['REQUEST_METHOD'] == 'HEAD'
20
+ response = handle(env, head: head)
21
+ return response unless head
22
+
23
+ status, headers, _body = response
24
+ [status, headers, []]
25
+ end
26
+
27
+ def close
28
+ @event_hub&.close
29
+ @snapshot.close
30
+ self
31
+ end
32
+
33
+ private
34
+
35
+ def handle(env, head:)
36
+ return Response.unauthorized unless @auth.authorized?(env)
37
+
38
+ route = @router.match(env)
39
+ return Response.not_found unless route
40
+
41
+ if head && route == :stream
42
+ return @config.transport == :sse ? [200, Response.sse_headers, []] : Response.not_found
43
+ end
44
+
45
+ dispatch(route, env)
46
+ rescue RequestError => error
47
+ Response.bad_request(error.message)
48
+ rescue UnavailableError, ClosedError
49
+ Response.unavailable
50
+ rescue StandardError => error
51
+ # Do not turn internal class names, paths or database errors into an
52
+ # unauthenticated information disclosure channel — but do surface
53
+ # them to the operator via the configured logger.
54
+ log_internal_error(env, error)
55
+ Response.internal_error
56
+ end
57
+
58
+ def build_metrics_reader
59
+ return unless @config.metrics_enabled?
60
+
61
+ MetricsReader.new(path: @config.metrics_path, total_workers: @config.total_workers)
62
+ end
63
+
64
+ def build_event_hub
65
+ return unless @config.transport == :sse
66
+
67
+ EventHub.new(@snapshot, @serializer, metrics_reader: @metrics_reader)
68
+ end
69
+
70
+ def dispatch(route, env)
71
+ case route
72
+ when :index then Response.html(Assets.render_index(@config))
73
+ when :javascript then Response.javascript(Assets::JS)
74
+ when :stylesheet then Response.stylesheet(Assets::CSS)
75
+ when :overview then overview_response
76
+ when :executing then in_flight_response(:executing, env)
77
+ when :claimed then in_flight_response(:claimed, env)
78
+ when :done then terminal_response(:done, env)
79
+ when :failed then terminal_response(:failed, env)
80
+ when :pending then pending_response(env)
81
+ when :metrics then metrics_response
82
+ when :config then config_response
83
+ when :stream then stream_response
84
+ else Response.not_found
85
+ end
86
+ end
87
+
88
+ def overview_response
89
+ Response.json(@serializer.overview(@snapshot.overview, metrics_payload))
90
+ end
91
+
92
+ def in_flight_response(kind, env)
93
+ request = Request.new(env, @config)
94
+ rows = kind == :executing ? @snapshot.executing(limit: request.limit) : @snapshot.claimed(limit: request.limit)
95
+ payload = kind == :executing ? @serializer.executing(rows) : @serializer.claimed(rows)
96
+ Response.json({items: payload})
97
+ end
98
+
99
+ def terminal_response(kind, env)
100
+ request = Request.new(env, @config)
101
+ cursor = request.finished_cursor
102
+ rows = kind == :done ? @snapshot.recent_done(limit: request.limit, cursor: cursor) :
103
+ @snapshot.recent_failed(limit: request.limit, cursor: cursor)
104
+ payload = kind == :done ? @serializer.done(rows) : @serializer.failed(rows)
105
+ Response.json(payload)
106
+ end
107
+
108
+ def pending_response(env)
109
+ request = Request.new(env, @config)
110
+ rows = @snapshot.pending(limit: request.limit, cursor: request.pending_cursor)
111
+ Response.json(@serializer.pending(rows))
112
+ end
113
+
114
+ def metrics_response
115
+ Response.json(metrics_payload || {available: false, workers: [], totals: MetricsReader::EMPTY_TOTALS})
116
+ end
117
+
118
+ def metrics_payload
119
+ @metrics_reader&.aggregated
120
+ end
121
+
122
+ def config_response
123
+ Response.json(
124
+ {
125
+ title: @config.title,
126
+ poll_interval_ms: @config.poll_interval_ms,
127
+ transport: @config.transport.to_s,
128
+ expose_args: @config.expose_args,
129
+ list_limit: @config.list_limit,
130
+ mount_path: @config.mount_path
131
+ }
132
+ )
133
+ end
134
+
135
+ def stream_response
136
+ return Response.not_found unless @config.transport == :sse
137
+
138
+ Response.sse(
139
+ Stream.new(
140
+ @event_hub,
141
+ heartbeat_seconds: @config.stream_heartbeat_seconds,
142
+ retry_ms: @config.stream_retry_ms,
143
+ poll_seconds: @config.stream_poll_seconds,
144
+ logger: @logger
145
+ )
146
+ )
147
+ end
148
+
149
+ def log_internal_error(env, error)
150
+ @logger&.error(
151
+ "[async-background-web] internal error on " \
152
+ "#{env['REQUEST_METHOD']} #{env['PATH_INFO']}: " \
153
+ "#{error.class}: #{error.message}"
154
+ )
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end