queue_pulse 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ce1008bbb8305bb0a7c884df8ca88bcd44484c7f177ec1a400eee970c8387721
4
+ data.tar.gz: f7e29e0722bd4278169e254651b5266dbda28b77248cd928579b7318a546cfca
5
+ SHA512:
6
+ metadata.gz: 54437eb5f66dba1c5ea6db4d3208c1b18288c71656690a6f8212bb2a9d67a99d393b4e87da6e668425f5309b42326be3b90d40ddb477c64c5e2eb7d0f1ef1970
7
+ data.tar.gz: d75c324f3bc757080309e7b053365ebb79ff1fa77b4c94f2bf2a549d43d5743464f9dd54f3c80f294538dc55cb9a434a9a64e5f84c4aa61f9ea6823cd2a1914a
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ This project adheres to [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-06-09
9
+ Initial open-source release (Phase 1 โ€” the free core).
10
+
11
+ ### Added
12
+ - Read-only monitoring of Solid Queue with **no migration and no extra service**.
13
+ - Five checks: job failures (with burst-collapsing), queue latency, queue depth,
14
+ stuck (long-running) jobs, and worker-liveness (dead-worker) detection.
15
+ - Notifiers: Slack, generic Webhook, Email (ActionMailer), plus a Test notifier.
16
+ - Alert deduplication via a failed-execution high-water mark and per-condition cooldowns,
17
+ backed by `Rails.cache` (with an in-process fallback).
18
+ - Execution via `QueuePulse.check!`, `QueuePulse::CheckJob` (for Solid Queue recurring tasks),
19
+ a `queue_pulse:check` rake task, and an opt-in in-process poller.
20
+ - Full exception isolation: a failing check or notifier never propagates to the host app.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 QueuePulse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # QueuePulse
2
+
3
+ **Know the moment your Solid Queue jobs break โ€” without running Datadog.**
4
+
5
+ [Solid Queue](https://github.com/rails/solid_queue) is the default Active Job backend in Rails 8.
6
+ Its dashboard, [Mission Control Jobs](https://github.com/rails/mission_control-jobs), is great for
7
+ *looking* at jobs โ€” but it has **no alerting** and **no historical metrics**. So today you either
8
+ find out your jobs are failing when a customer emails you, or you bolt on a heavyweight APM
9
+ (Datadog/New Relic) that's overkill for a small app.
10
+
11
+ QueuePulse fills that gap. It reads Solid Queue's existing tables (read-only, **no migration, no extra
12
+ service**) and pings you the moment something goes wrong:
13
+
14
+ - ๐Ÿ”ด **Job failures** โ€” alerted the instant a job lands in `failed_executions`
15
+ - ๐ŸŸ  **Queue latency** โ€” oldest job has been waiting too long (work is backing up)
16
+ - ๐ŸŸ  **Queue depth** โ€” a queue is piling up beyond your threshold
17
+ - ๐ŸŸ  **Stuck jobs** โ€” a job has been running far longer than it should
18
+ - ๐Ÿ”ด **Dead workers** โ€” no worker/dispatcher heartbeat = nothing is processing jobs
19
+
20
+ Delivered to **Slack, email, or any webhook**.
21
+
22
+ > Status: early. The free gem (this repo) is the open-source core. A hosted dashboard with
23
+ > historical metrics and AI failure summaries is on the roadmap โ€”
24
+ > **[join the waitlist ยป](https://queue-pulse.vercel.app)**.
25
+
26
+ ## Install
27
+
28
+ ```ruby
29
+ # Gemfile
30
+ gem "queue_pulse"
31
+ ```
32
+
33
+ ```bash
34
+ bundle install
35
+ ```
36
+
37
+ ## Configure
38
+
39
+ ```ruby
40
+ # config/initializers/queue_pulse.rb
41
+ QueuePulse.configure do |config|
42
+ config.add_notifier QueuePulse::Notifiers::Slack.new(webhook_url: ENV["SLACK_WEBHOOK_URL"])
43
+
44
+ # Optional โ€” every setting has a sensible default:
45
+ config.queue_latency_threshold = 300 # seconds a job may wait before alerting
46
+ config.queue_depth_threshold = 1_000 # ready jobs per queue before alerting
47
+ config.stuck_job_threshold = 600 # seconds a running job may take before alerting
48
+ config.alert_cooldown = 900 # seconds before re-alerting the same condition
49
+ config.environment_label = Rails.env
50
+ end
51
+ ```
52
+
53
+ ### Run the checks
54
+
55
+ Schedule it with Solid Queue's own recurring tasks (recommended):
56
+
57
+ ```yaml
58
+ # config/recurring.yml
59
+ queue_pulse_check:
60
+ class: QueuePulse::CheckJob
61
+ schedule: every minute
62
+ ```
63
+
64
+ Or run on demand:
65
+
66
+ ```bash
67
+ bin/rails queue_pulse:check
68
+ ```
69
+
70
+ Or run an in-process poller (opt-in):
71
+
72
+ ```ruby
73
+ QueuePulse.start_poller! # checks every config.poll_interval seconds
74
+ ```
75
+
76
+ ## Notifiers
77
+
78
+ ```ruby
79
+ QueuePulse::Notifiers::Slack.new(webhook_url: "https://hooks.slack.com/...")
80
+ QueuePulse::Notifiers::Webhook.new(url: "https://example.com/hook", headers: { "Authorization" => "Bearer ..." })
81
+ QueuePulse::Notifiers::Email.new(to: "ops@example.com")
82
+ ```
83
+
84
+ Add your own by subclassing `QueuePulse::Notifiers::Base` and implementing `#deliver(alerts)`.
85
+
86
+ ## Design principles
87
+
88
+ - **No migration, no extra service.** Reads Solid Queue tables directly; dedupe state lives in `Rails.cache`.
89
+ - **Safe by default.** Read-only queries, every check and notifier is exception-isolated โ€” QueuePulse can never take down your app. Raw job arguments are never sent unless you opt in.
90
+ - **Quiet.** Cooldowns and burst-collapsing mean you get signal, not spam.
91
+
92
+ ## Requirements
93
+
94
+ Ruby โ‰ฅ 3.1, Rails โ‰ฅ 7.1, solid_queue โ‰ฅ 1.0.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ bundle install
100
+ rake test
101
+ ```
102
+
103
+ ## License
104
+
105
+ MIT โ€” see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueuePulse
4
+ # An immutable description of something worth notifying about.
5
+ #
6
+ # fingerprint is the dedupe/cooldown key: alerts sharing a fingerprint are
7
+ # considered "the same ongoing condition" and are rate-limited together.
8
+ class Alert
9
+ SEVERITIES = %i[info warning critical].freeze
10
+
11
+ attr_reader :type, :severity, :title, :message, :context, :fingerprint
12
+
13
+ def initialize(type:, severity:, title:, message:, fingerprint:, context: {})
14
+ @type = type.to_sym
15
+ @severity = severity.to_sym
16
+ unless SEVERITIES.include?(@severity)
17
+ raise ArgumentError, "severity must be one of #{SEVERITIES.inspect}"
18
+ end
19
+
20
+ @title = title
21
+ @message = message
22
+ @context = context.freeze
23
+ @fingerprint = fingerprint
24
+ freeze
25
+ end
26
+
27
+ def to_h
28
+ {
29
+ type: type,
30
+ severity: severity,
31
+ title: title,
32
+ message: message,
33
+ context: context,
34
+ fingerprint: fingerprint
35
+ }
36
+ end
37
+
38
+ SEVERITY_EMOJI = { info: "๐Ÿ”ต", warning: "๐ŸŸ ", critical: "๐Ÿ”ด" }.freeze
39
+
40
+ def emoji
41
+ SEVERITY_EMOJI.fetch(severity, "โšช๏ธ")
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueuePulse
4
+ # Schedule this via Solid Queue recurring tasks to run checks periodically:
5
+ #
6
+ # # config/recurring.yml
7
+ # queue_pulse_check:
8
+ # class: QueuePulse::CheckJob
9
+ # schedule: every minute
10
+ class CheckJob < ActiveJob::Base
11
+ queue_as :queue_pulse
12
+
13
+ def perform
14
+ QueuePulse.check!
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueuePulse
4
+ module Checks
5
+ # Checks are pure-ish: given (config, source, state) they return Alerts.
6
+ # The only side effect they own is updating dedupe/cooldown state at the
7
+ # moment they decide to emit (keeps cooldown logic next to the data that
8
+ # produces it). Delivery is the Monitor's job.
9
+ class Base
10
+ def initialize(config, source, state)
11
+ @config = config
12
+ @source = source
13
+ @state = state
14
+ end
15
+
16
+ # @return [Array<QueuePulse::Alert>]
17
+ def call
18
+ raise NotImplementedError
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :config, :source, :state
24
+
25
+ # Emit only if this fingerprint is outside its cooldown window, marking it
26
+ # so it won't re-alert until the window passes (Requirements US-3.3/3.4).
27
+ def with_cooldown(fingerprint)
28
+ return nil if state.alerted_recently?(fingerprint)
29
+
30
+ alert = yield
31
+ state.mark_alerted(fingerprint, config.alert_cooldown) if alert
32
+ alert
33
+ end
34
+
35
+ def env_suffix
36
+ config.environment_label ? " [#{config.environment_label}]" : ""
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueuePulse
4
+ module Checks
5
+ # Detects newly failed jobs since the last pass using a high-water mark on
6
+ # solid_queue_failed_executions.id. On first ever run it establishes the
7
+ # baseline silently (so installing the gem doesn't replay history).
8
+ # Above failure_burst_threshold it collapses into a single summary alert.
9
+ # (Requirements US-2.)
10
+ class Failures < Base
11
+ def call
12
+ hwm = state.failed_high_water_mark
13
+
14
+ if hwm.nil?
15
+ baseline = source.max_failed_execution_id || 0
16
+ state.set_failed_high_water_mark(baseline)
17
+ return []
18
+ end
19
+
20
+ burst = config.failure_burst_threshold
21
+ recent = source.failed_executions_since(hwm, limit: burst + 1)
22
+ return [] if recent.empty?
23
+
24
+ new_hwm = recent.map(&:id).max
25
+
26
+ if recent.size > burst
27
+ total = source.failed_count_since(hwm)
28
+ state.set_failed_high_water_mark(source.max_failed_execution_id || new_hwm)
29
+ [burst_alert(total)]
30
+ else
31
+ state.set_failed_high_water_mark(new_hwm)
32
+ recent.map { |f| failure_alert(f) }
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def failure_alert(failure)
39
+ Alert.new(
40
+ type: :job_failed,
41
+ severity: :critical,
42
+ title: "Job failed: #{failure.job_class}#{env_suffix}",
43
+ message: "#{failure.job_class} on queue \"#{failure.queue_name}\" failed: #{failure.error_message}",
44
+ fingerprint: "job_failed:#{failure.id}",
45
+ context: {
46
+ job_class: failure.job_class,
47
+ queue_name: failure.queue_name,
48
+ error: failure.error_message,
49
+ failed_at: failure.failed_at
50
+ }
51
+ )
52
+ end
53
+
54
+ def burst_alert(total)
55
+ Alert.new(
56
+ type: :job_failure_burst,
57
+ severity: :critical,
58
+ title: "#{total} jobs failed#{env_suffix}",
59
+ message: "#{total} background jobs failed since the last check. " \
60
+ "Open Solid Queue / Mission Control to inspect.",
61
+ fingerprint: "job_failure_burst",
62
+ context: { count: total }
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueuePulse
4
+ module Checks
5
+ # Alerts when a queue's ready depth exceeds queue_depth_threshold.
6
+ # Cooldown is per-queue. (Requirements US-3.2, US-3.3.)
7
+ class QueueDepth < Base
8
+ def call
9
+ threshold = config.queue_depth_threshold
10
+
11
+ source.queue_stats.filter_map do |stat|
12
+ next if stat.depth < threshold
13
+
14
+ with_cooldown("queue_depth:#{stat.queue_name}") do
15
+ depth_alert(stat)
16
+ end
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def depth_alert(stat)
23
+ Alert.new(
24
+ type: :queue_depth,
25
+ severity: :warning,
26
+ title: "Queue \"#{stat.queue_name}\" is backing up#{env_suffix}",
27
+ message: "\"#{stat.queue_name}\" has #{stat.depth} jobs waiting " \
28
+ "(threshold #{config.queue_depth_threshold}).",
29
+ fingerprint: "queue_depth:#{stat.queue_name}",
30
+ context: { queue_name: stat.queue_name, depth: stat.depth }
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueuePulse
4
+ module Checks
5
+ # Alerts when the oldest ready job in a queue has been waiting longer than
6
+ # queue_latency_threshold โ€” i.e. work is backing up before users feel it.
7
+ # Cooldown is per-queue. (Requirements US-3.1, US-3.3, US-3.4.)
8
+ class QueueLatency < Base
9
+ def call
10
+ threshold = config.queue_latency_threshold
11
+ now = source.now
12
+
13
+ source.queue_stats.filter_map do |stat|
14
+ next unless stat.oldest_enqueued_at
15
+
16
+ latency = (now - stat.oldest_enqueued_at).to_i
17
+ next if latency < threshold
18
+
19
+ with_cooldown("queue_latency:#{stat.queue_name}") do
20
+ latency_alert(stat, latency)
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def latency_alert(stat, latency)
28
+ Alert.new(
29
+ type: :queue_latency,
30
+ severity: :warning,
31
+ title: "Queue \"#{stat.queue_name}\" is delayed#{env_suffix}",
32
+ message: "Oldest job in \"#{stat.queue_name}\" has waited #{latency}s " \
33
+ "(threshold #{config.queue_latency_threshold}s), #{stat.depth} jobs waiting.",
34
+ fingerprint: "queue_latency:#{stat.queue_name}",
35
+ context: { queue_name: stat.queue_name, latency_seconds: latency, depth: stat.depth }
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueuePulse
4
+ module Checks
5
+ # Alerts on jobs that have been claimed (running) longer than
6
+ # stuck_job_threshold. Cooldown is per job_id so each stuck job alerts once
7
+ # per window. (Requirements US-4.)
8
+ class StuckJobs < Base
9
+ def call
10
+ threshold = config.stuck_job_threshold
11
+ now = source.now
12
+
13
+ source.stuck_claimed_executions(older_than: threshold).filter_map do |claimed|
14
+ running = (now - claimed.claimed_at).to_i
15
+
16
+ with_cooldown("stuck_job:#{claimed.job_id}") do
17
+ stuck_alert(claimed, running)
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def stuck_alert(claimed, running)
25
+ Alert.new(
26
+ type: :stuck_job,
27
+ severity: :warning,
28
+ title: "Job stuck: #{claimed.job_class}#{env_suffix}",
29
+ message: "#{claimed.job_class} on \"#{claimed.queue_name}\" has been running #{running}s " \
30
+ "(threshold #{config.stuck_job_threshold}s). It may be hung.",
31
+ fingerprint: "stuck_job:#{claimed.job_id}",
32
+ context: {
33
+ job_id: claimed.job_id,
34
+ job_class: claimed.job_class,
35
+ queue_name: claimed.queue_name,
36
+ running_seconds: running
37
+ }
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueuePulse
4
+ module Checks
5
+ # Alerts when no worker/dispatcher process has a fresh heartbeat โ€” meaning
6
+ # nothing is processing jobs. Critical, because the queue silently stops.
7
+ # (Requirements US-5.) Only fires when at least one process was ever
8
+ # registered, to avoid false alarms before the first boot.
9
+ class WorkerLiveness < Base
10
+ def call
11
+ return [] if source.total_process_count.to_i.zero?
12
+
13
+ live = source.live_process_count(heartbeat_within: config.worker_heartbeat_threshold)
14
+ return [] if live.positive?
15
+
16
+ alert = with_cooldown("workers_down") { workers_down_alert }
17
+ Array(alert)
18
+ end
19
+
20
+ private
21
+
22
+ def workers_down_alert
23
+ Alert.new(
24
+ type: :workers_down,
25
+ severity: :critical,
26
+ title: "No live Solid Queue workers#{env_suffix}",
27
+ message: "No worker/dispatcher has sent a heartbeat in the last " \
28
+ "#{config.worker_heartbeat_threshold}s. Background jobs are NOT being processed.",
29
+ fingerprint: "workers_down",
30
+ context: { heartbeat_threshold: config.worker_heartbeat_threshold }
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueuePulse
4
+ # Holds all tunable settings. Sensible defaults mean a user only has to add a
5
+ # notifier to get value. See docs/requirements.md (US-1, US-3).
6
+ class Configuration
7
+ # All checks shipped in Phase 1, in evaluation order.
8
+ ALL_CHECKS = %i[failures queue_latency queue_depth stuck_jobs worker_liveness].freeze
9
+
10
+ attr_accessor :enabled,
11
+ :poll_interval,
12
+ :queue_latency_threshold,
13
+ :queue_depth_threshold,
14
+ :stuck_job_threshold,
15
+ :worker_heartbeat_threshold,
16
+ :alert_cooldown,
17
+ :failure_burst_threshold,
18
+ :check_timeout,
19
+ :notifiers,
20
+ :enabled_checks,
21
+ :environment_label,
22
+ :include_arguments,
23
+ :logger
24
+
25
+ attr_writer :cache
26
+
27
+ def initialize
28
+ @enabled = true
29
+ @poll_interval = 30 # seconds (Railtie poller)
30
+ @queue_latency_threshold = 300 # seconds a job may wait before alerting
31
+ @queue_depth_threshold = 1_000 # ready jobs per queue before alerting
32
+ @stuck_job_threshold = 600 # seconds a claimed job may run before alerting
33
+ @worker_heartbeat_threshold = 120 # seconds before a process is "dead"
34
+ @alert_cooldown = 900 # seconds to suppress duplicate state alerts
35
+ @failure_burst_threshold = 25 # collapse into a summary above this many
36
+ @check_timeout = 25 # seconds; abort a slow pass
37
+ @notifiers = []
38
+ @enabled_checks = ALL_CHECKS.dup
39
+ @environment_label = default_environment
40
+ @include_arguments = false # never send raw job arguments by default
41
+ @logger = default_logger
42
+ @cache = nil
43
+ end
44
+
45
+ def add_notifier(notifier)
46
+ @notifiers << notifier
47
+ notifier
48
+ end
49
+
50
+ def check_enabled?(name)
51
+ @enabled_checks.include?(name)
52
+ end
53
+
54
+ # Cache store for dedupe/cooldown state. Prefers Rails.cache, falls back to
55
+ # an in-process MemoryStore so the gem works even without a configured cache.
56
+ def cache
57
+ @cache ||= rails_cache || MemoryStore.new
58
+ end
59
+
60
+ private
61
+
62
+ def rails_cache
63
+ return nil unless defined?(Rails)
64
+
65
+ store = Rails.respond_to?(:cache) ? Rails.cache : nil
66
+ # A NullStore would silently drop our dedupe state โ†’ prefer MemoryStore.
67
+ return nil if store.nil? || store.class.name.to_s.include?("NullStore")
68
+
69
+ store
70
+ end
71
+
72
+ def default_environment
73
+ return ENV["RAILS_ENV"] if ENV["RAILS_ENV"]
74
+ return Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env)
75
+
76
+ ENV["RACK_ENV"] || "development"
77
+ end
78
+
79
+ def default_logger
80
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
81
+ Rails.logger
82
+ else
83
+ Logger.new($stdout).tap { |l| l.level = Logger::INFO }
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueuePulse
4
+ # Minimal in-process cache used as a fallback when Rails.cache is unavailable
5
+ # (e.g. plain Ruby tests, or apps with no cache store configured).
6
+ #
7
+ # Implements the small subset of the ActiveSupport::Cache interface QueuePulse
8
+ # relies on: #read, #write (with :expires_in), #delete. Thread-safe.
9
+ class MemoryStore
10
+ Entry = Struct.new(:value, :expires_at)
11
+
12
+ def initialize
13
+ @data = {}
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def read(key)
18
+ @mutex.synchronize do
19
+ entry = @data[key]
20
+ return nil unless entry
21
+ if entry.expires_at && entry.expires_at <= monotonic_now
22
+ @data.delete(key)
23
+ return nil
24
+ end
25
+ entry.value
26
+ end
27
+ end
28
+
29
+ def write(key, value, expires_in: nil)
30
+ @mutex.synchronize do
31
+ expires_at = expires_in ? monotonic_now + expires_in : nil
32
+ @data[key] = Entry.new(value, expires_at)
33
+ end
34
+ true
35
+ end
36
+
37
+ def delete(key)
38
+ @mutex.synchronize { @data.delete(key) }
39
+ true
40
+ end
41
+
42
+ private
43
+
44
+ def monotonic_now
45
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
46
+ end
47
+ end
48
+ end