karafka 2.6.0 → 2.6.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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +1 -1
  4. data/config/locales/pro_errors.yml +2 -0
  5. data/lib/karafka/connection/pauses_manager.rb +27 -4
  6. data/lib/karafka/constraints.rb +59 -19
  7. data/lib/karafka/messages/metadata.rb +11 -2
  8. data/lib/karafka/pro/encryption/cipher.rb +51 -21
  9. data/lib/karafka/pro/encryption/ciphers/base.rb +92 -0
  10. data/lib/karafka/pro/encryption/ciphers/direct.rb +74 -0
  11. data/lib/karafka/pro/encryption/ciphers/envelope.rb +164 -0
  12. data/lib/karafka/pro/encryption/contracts/config.rb +1 -0
  13. data/lib/karafka/pro/encryption/setup/config.rb +22 -0
  14. data/lib/karafka/pro/encryption.rb +34 -0
  15. data/lib/karafka/pro/iterator.rb +5 -0
  16. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/default.rb +1 -1
  17. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/vp.rb +1 -1
  18. data/lib/karafka/pro/processing/consumer_groups/strategies/ftr/default.rb +1 -1
  19. data/lib/karafka/pro/processing/jobs_queue.rb +13 -3
  20. data/lib/karafka/pro/routing/features/consumer_groups/patterns/detector.rb +27 -4
  21. data/lib/karafka/pro/scheduled_messages/consumer.rb +9 -12
  22. data/lib/karafka/pro/scheduled_messages/contracts/message.rb +17 -2
  23. data/lib/karafka/pro/scheduled_messages/dispatcher.rb +46 -5
  24. data/lib/karafka/processing/consumer_groups/coordinators_buffer.rb +5 -4
  25. data/lib/karafka/server.rb +5 -2
  26. data/lib/karafka/setup/config.rb +5 -37
  27. data/lib/karafka/swarm/node.rb +2 -0
  28. data/lib/karafka/swarm/supervisor.rb +4 -2
  29. data/lib/karafka/version.rb +1 -1
  30. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00213ecae5aa07409c67a645f32e89beeab05399cb8e323a86bbcd8af538dd03
4
- data.tar.gz: b790e3752b012e70095fa97510617f833107ca74282f721cb7afe378e4285e49
3
+ metadata.gz: a2d4a5f8788806e379defbbbd60e3ba605396b2cfed7fd78211ea5f14724c1b4
4
+ data.tar.gz: d9a183f86bb901603eec8dcaa1b98861fa48ded21ef9b8d95e47d3e100bd9f85
5
5
  SHA512:
6
- metadata.gz: b3f03f3ba3d3bcdb616f3dc666e719224947a8de3a4a72ed1c4162da76f903bc99365a51bffe5e43a18894c2426756f75825e3b09e6174c47ad385c5aabb26e6
7
- data.tar.gz: bf1ce2ca9cf4444349c2bf81c9b4c896f70a29b02a0415ef149d1f000705caf324b61513ddf348ee5dfb1bb763ce22de09227bde7ab2f76c79d21ed58992d05b
6
+ metadata.gz: 7f418e4dfac658f95fb12d1190e26d903e31d5ea28e38234c3bcb9eb90aee242e11af8f344544d51f3dfa0fe560aa9328a4517c01152c41773c625c99e3118c1
7
+ data.tar.gz: f15f728a174c61cb9d1ab56280f69d98dcee9d4f2e515b5c085bc6b69f2e87b6c8ad4c521f8cb0cf844252eb1d5dec813c4efda2954c7d3b7304c8973fb44cf1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Karafka Framework Changelog
2
2
 
3
+ ## 2.6.1 (2026-08-24)
4
+ - **[Feature]** [Pro] Add an opt-in envelope encryption mode for Messages At Rest encryption (`config.encryption.mode = :envelope`, requires the openssl gem `>= 3.0`). It removes the RSA payload size ceiling of the default `:direct` mode (~245 bytes for 2048-bit keys) by encrypting each message with a one-time RSA-wrapped AES-256-GCM key. Both formats are recognized automatically on decryption, so data already at rest stays readable with no migration. Deployment: upgrade all consuming processes before enabling - older versions cannot decrypt envelope payloads, while upgraded consumers read both formats and keep producing `:direct` until switched, so the rollout can be staged safely. The default will switch to `:envelope` in a future release with prior notice; the `:direct` format stays decryptable indefinitely as data at rest never expires. Cipher key material is now pre-parsed (warmed) during setup - this introduces no new boot failure modes, as the configuration contract has always parsed and validated all configured keys at boot.
5
+ - [Fix] [Pro] Add a missing comma in the filtering strategy's unsupported-action guard so it raises `Karafka::Errors::UnsupportedCaseError` instead of a `NoMethodError`.
6
+ - [Fix] [Pro] Evict scheduled messages from the daily buffer per confirmed flush chunk instead of only after the whole flush. A broker error partway through a multi-chunk flush left the already-produced chunks in the buffer, re-dispatching them on the next tick (a duplicate window for non-transactional producers). Chunk size is now rounded up to even before shifting off the buffer, so a message's target and its own tombstone (always buffered as an adjacent pair) can no longer land in different chunks - with an odd or `1` `flush_batch_size`, the target's chunk could confirm and evict the key before the tombstone chunk ran, leaving the schedule non-tombstoned in Kafka (and re-dispatched after a restart/reload) if that later chunk then failed.
7
+ - [Fix] Prevent the `Karafka::Server#stop` specs from leaking a sub-second `shutdown_timeout` into the global config. The `#stop` examples mutate `Karafka::App.config.shutdown_timeout` (down to `500` ms, below the default `max_wait_time` of `1_000` ms) but never restored it, so with a random run order the invalid value bled into unrelated specs and made them fail contract validation with `shutdown_timeout must be more than max_wait_time`. The original value is now captured and restored (and the memoized class-level value cleared) in the example teardown.
8
+ - [Fix] [Pro] Reject a `schedule_target_epoch` set implausibly far in the future (e.g. a milliseconds value passed as seconds) at publish time instead of silently dropping it on the consumer.
9
+ - [Fix] [Pro] Raise `Karafka::Errors::UnsupportedCaseError` instead of a `NameError` (wrong constant namespace) when a custom DLQ strategy returns an unsupported flow, in both the default and virtual-partitions DLQ strategies.
10
+ - [Enhancement] Support named `Karafka::Constraints` registered in two phases: `:load` (require time) and `:config` (during setup, after contract validation), so feature-specific environment requirements are verified in one place.
11
+ - [Fix] Message `key` and `headers` deserialization results were not cached when the deserializer returned `nil` or `false` (e.g. keyless messages), re-running the deserializer on every access. Falsy results are now cached like truthy ones, matching the payload behavior and the documented contract.
12
+ - [Fix] [Pro] Reset the `Pro::Iterator` stored-offsets latch between runs. `#mark_as_consumed` set an internal `@stored_offsets` flag that was never cleared, so once a reused iterator marked anything, the teardown guard fired a spurious blocking sync `commit_offsets` on the end of every subsequent `#each` - each running on a brand-new consumer with no stored offsets. The flag is now reset alongside the other per-run state in the `#each` `ensure` block, so it reflects only the current run's marking activity (benign no-op commit removed; no correctness impact).
13
+ - [Fix] [Pro] Deduplicate runtime pattern discovery under multiplexing. Multiplexed subscription groups share a single consumer group but each runs its own listener thread and independently discovers the same topic; `ConsumerGroup#topic=` appended unconditionally, so the shared consumer group accumulated up to `multiplex_factor` duplicate `Topic` objects per discovered topic (polluting the routing tree and slowing linear topic scans, though the per-subscription-group consume path stayed correct). The detector now registers each discovered topic in the shared group only once (under its existing mutex) while still giving every subscription group its own `Topic` instance, mirroring how the multiplexing subscription groups builder dups topics per group at boot.
14
+ - [Fix] Fix sub-second `shutdown_timeout` values collapsing to a zero-iteration supervision loop. The graceful-shutdown wait computed its iteration count with integer division (`shutdown_timeout / 1_000`), so any configured `shutdown_timeout` below 1000ms (valid config, as long as it exceeds `max_wait_time`) floored to zero iterations, skipping the grace period entirely and forcing an immediate forceful shutdown (killing in-flight jobs before their offset commits). The count is now computed with float math and `ceil`, guaranteeing at least one supervision check for any positive timeout. The swarm supervisor shared the same formula (masked by the added grace period) and was fixed the same way.
15
+ - [Fix] Remove the backwards-compatible forwarding shim that kept the pre-nesting `config.internal.processing.*` paths (`coordinator_class`, `errors_tracker_class`, `partitioner_class`, `strategy_selector`, `expansions_selector`, `executor_class`, `jobs_builder`) resolving after the 2.6.0 move to `config.internal.processing.consumer_groups.*`. The shim was shipped by accident; the nesting was announced as having no backward compatibility. Use the nested `config.internal.processing.consumer_groups.*` paths instead. Requires `karafka-testing >= 2.6.2`, which reads these settings from their nested location.
16
+ - [Fix] Stop `PausesManager#@pauses` from growing unbounded across rebalances. `CoordinatorsBuffer#revoke` reset the pause tracker's retry attempt count on revocation but never removed it, and nothing else pruned the hash outside of critical-error recovery, so a `Pause` (and its `Mutex`) was retained forever for every distinct topic-partition ever assigned - harmless for static routing (`Topic` objects are stable and get reused on reassignment) but unbounded under regex pattern subscriptions with ephemeral, per-discovery topic names. `#revoke` now removes a tracker outright when it is not currently paused (added `PausesManager#delete`), since it carries no state worth preserving; a tracker that is paused is still reset-and-kept, unchanged, because its remaining backoff may need to be honoured again if the same partition is reclaimed (routine under eager rebalancing, where every previously owned partition is revoked and reassigned even when nothing has actually changed for it).
17
+ - [Fix] Allow-list the benign auto-create `TOPIC_ALREADY_EXISTS` broker warning in `unexpected_patterns_loop_spec.rb`, the same pre-existing broker-side race already allow-listed for other pattern-matched-topic specs, unrelated to this release's other fixes.
18
+ - [Fix] [Pro] Fix unbounded memory growth from `Pro::Processing::JobsQueue`'s per-group semaphore accumulating unconsumed `#tick` signals under non-blocking/LRJ and async-locking workloads.
19
+ - [Fix] [Pro] Stop `Pro::Processing::JobsQueue#unlock` from decrementing `@statistics[:waiting]` before confirming the job was actually tracked. It unconditionally decremented first and only afterwards checked whether the job was still present in `@in_waiting`, so a job unlocked after its subscription group had already been reset via `#clear` (which decrements `waiting` itself as part of recovery) both over-decremented the counter a second time and raised `JobsQueueSynchronizationError` - the counter corruption was silent while the (still legitimate) synchronization error masked it. `#unlock` now only decrements when the job is found, mirroring `#unlock_async`'s existing check-then-act pattern; both raise consistently when the group has already been cleared.
20
+ - [Fix] `Swarm::Node#signal` (used by `#stop`/`#terminate`/`#quiet`) could send a signal to a reaped node's stale `@pid`, which the OS may have already reassigned to an unrelated process. It now returns `false` without signaling if the node is already known to be dead.
21
+
3
22
  ## 2.6.0 (2026-08-05)
4
23
  - **[Breaking]** Remove the flat global pause configuration accessors (`config.pause_timeout`, `config.pause_max_timeout`, `config.pause_with_exponential_backoff`) deprecated in 2.5.2. Use the nested `config.pause.*` namespace (`config.pause.timeout`, `config.pause.max_timeout`, `config.pause.with_exponential_backoff`) instead.
5
24
  - **[Breaking]** Nest the per-topic pause configuration under `topic.pause`. The flat `topic.pause_timeout`, `topic.pause_max_timeout`, and `topic.pause_with_exponential_backoff` readers are removed in favor of `topic.pause.timeout`, `topic.pause.max_timeout`, and `topic.pause.with_exponential_backoff`; `topic.to_h` now emits a nested `pause:` hash instead of the flat keys. The Pro Granular Backoffs override DSL is unchanged (`pause(timeout:, max_timeout:, with_exponential_backoff:)`), and `topic.pausing`/`topic.pausing?` are now `topic.pause`/`topic.pause?`. In OSS `topic.pause` reflects the global `config.pause.*` settings; per-topic overriding remains a Pro feature.
data/README.md CHANGED
@@ -14,7 +14,7 @@ Karafka is a Ruby and Rails multi-threaded efficient Kafka processing framework
14
14
  - Has [ActiveJob backend](https://karafka.io/docs/Active-Job) support (including [ordered jobs](https://karafka.io/docs/Pro-Enhanced-Active-Job#ordered-jobs))
15
15
  - Has a seamless [Dead Letter Queue](https://karafka.io/docs/Dead-Letter-Queue/) functionality built-in
16
16
  - Supports in-development [code reloading](https://karafka.io/docs/Auto-reload-of-code-changes-in-development)
17
- - Is powered by [librdkafka](https://github.com/edenhill/librdkafka) (the Apache Kafka C/C++ client library)
17
+ - Is powered by [librdkafka](https://github.com/confluentinc/librdkafka) (the Apache Kafka C/C++ client library)
18
18
  - Has an out-of the box [AppSignal](https://karafka.io/docs/Monitoring-and-Logging/#appsignal-metrics-and-error-tracking) and [StatsD/DataDog](https://karafka.io/docs/Monitoring-and-Logging/#datadog-and-statsd-integration) monitoring with dashboard templates.
19
19
 
20
20
  ```ruby
@@ -115,6 +115,7 @@ en:
115
115
  setup:
116
116
  config:
117
117
  encryption.active_format: 'needs to be either true or false'
118
+ encryption.mode_format: 'needs to be either :envelope or :direct'
118
119
  encryption.public_key_invalid: 'is not a valid public RSA key'
119
120
  encryption.public_key_needs_to_be_public: 'is a private RSA key not a public one'
120
121
  encryption.private_keys_format: 'needs to be a hash of version and private key value'
@@ -156,5 +157,6 @@ en:
156
157
  key_missing: must be present and should be unique within the partition
157
158
  key_format: needs to be a non-empty string unique within the partition
158
159
  headers_schedule_target_epoch_in_the_past: 'scheduling cannot happen in the past'
160
+ headers_schedule_target_epoch_too_far_in_future: 'scheduling is too far in the future (schedule_target_epoch must be Unix time in seconds)'
159
161
  headers_format: are not correct
160
162
  not_a_scheduled_messages_topic: 'the envelope topic is not a scheduled messages topic'
@@ -25,12 +25,19 @@ module Karafka
25
25
  )
26
26
  end
27
27
 
28
- # Resets the attempt count of a given topic partition pause tracker.
28
+ # Resets the attempt count of a given topic partition pause tracker, or removes it entirely
29
+ # if it is not currently paused.
29
30
  #
30
31
  # Used on revocation so that a later reclaim of the same partition starts counting retry
31
32
  # attempts from zero instead of carrying the stale count across the rebalance. We reset
32
- # rather than remove the tracker because the pause itself may still need to be resumed after
33
- # the reclaim (the partition can be re-paused via the retained paused offsets on rebalance).
33
+ # rather than remove a tracker that is currently paused because the pause itself may still
34
+ # need to be resumed after the reclaim (the partition can be re-paused via the retained
35
+ # paused offsets on rebalance) - this matters under eager rebalancing, where every
36
+ # previously owned partition is revoked and then reassigned even when nothing has actually
37
+ # changed for it. A tracker that is not paused has no state worth preserving, so we remove
38
+ # it instead via `#delete` - this is what actually bounds `@pauses`, since otherwise entries
39
+ # accumulate forever for topics whose routing `Topic` object is never reused across
40
+ # reassignment (e.g. regex pattern subscriptions with ephemeral, per-discovery topic names).
34
41
  #
35
42
  # A coordinator and its pause tracker are created together in
36
43
  # `CoordinatorsBuffer#find_or_create`, and `CoordinatorsBuffer#revoke` only calls us once it
@@ -39,7 +46,23 @@ module Karafka
39
46
  # @param topic [::Karafka::Routing::Topic] topic
40
47
  # @param partition [Integer] partition number
41
48
  def revoke(topic, partition)
42
- @pauses[topic][partition].reset
49
+ pause = @pauses[topic][partition]
50
+
51
+ return delete(topic, partition) unless pause.paused?
52
+
53
+ pause.reset
54
+ end
55
+
56
+ # Removes the pause tracker of a given topic partition, dropping the topic entry entirely
57
+ # once it no longer tracks any partitions.
58
+ #
59
+ # @param topic [::Karafka::Routing::Topic] topic
60
+ # @param partition [Integer] partition number
61
+ def delete(topic, partition)
62
+ partitions = @pauses[topic]
63
+ partitions.delete(partition)
64
+
65
+ @pauses.delete(topic) if partitions.empty?
43
66
  end
44
67
 
45
68
  # Resumes processing of partitions for which pause time has ended.
@@ -1,32 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Karafka
4
- # Module used to check some constraints that cannot be easily defined by Bundler
5
- # At the moment we use it to ensure, that if Karafka is used, it operates with the expected
6
- # web ui version and that older versions of Web UI that would not be compatible with the API
7
- # changes in karafka are not used.
4
+ # Module used to check optional requirements (constraints) that cannot be easily defined by
5
+ # Bundler. Constraints are registered under one of two phases and verified centrally, so all
6
+ # environment requirements live and surface in one place:
8
7
  #
9
- # We can make Web UI require certain karafka version range, but at the moment we do not have a
10
- # strict 1:1 release pattern matching those two.
8
+ # - `:load` - verified when karafka itself is required, for requirements independent of the
9
+ # configuration (like ecosystem gems version compatibility)
10
+ # - `:config` - verified during `Karafka::App.setup` right after the configuration is
11
+ # validated, for requirements that depend on what features are actually enabled (features
12
+ # register those themselves, keeping their specifics out of this generic module)
11
13
  module Constraints
14
+ # Phases in which constraints can be verified
15
+ PHASES = %i[load config].freeze
16
+
17
+ private_constant :PHASES
18
+
12
19
  class << self
13
- # Verifies that optional requirements are met.
14
- def verify!
15
- # Skip verification if web is not used at all
16
- return unless require_version("karafka/web")
17
-
18
- # All good if version higher than 1.0.0.rc1 because we expect 1.0.0.rc2 or higher
19
- return if version(Karafka::Web::VERSION) >= version("1.0.0.rc2")
20
-
21
- # If older web-ui used, we cannot allow it
22
- raise(
23
- Errors::DependencyConstraintsError,
24
- "karafka-web < 1.0.0.rc2 is not compatible with this karafka version"
25
- )
20
+ # Registers a constraint for verification. Registrations are keyed by name, so
21
+ # re-registration (e.g. when setup runs multiple times in tests) overwrites a previous
22
+ # one instead of accumulating duplicates.
23
+ #
24
+ # @param name [Symbol] unique constraint name
25
+ # @param phase [Symbol] `:load` or `:config`
26
+ # @param block [Proc] verification receiving the config node (`nil` in the `:load`
27
+ # phase) and expected to raise `Karafka::Errors::DependencyConstraintsError` when the
28
+ # requirement is not met
29
+ def register(name, phase:, &block)
30
+ raise(Errors::UnsupportedCaseError, phase) unless PHASES.include?(phase)
31
+
32
+ constraints[phase][name] = block
33
+ end
34
+
35
+ # Verifies all the constraints registered for a given phase
36
+ #
37
+ # @param phase [Symbol] `:load` or `:config`
38
+ # @param config [Karafka::Core::Configurable::Node, nil] config node for the `:config`
39
+ # phase, `nil` in the `:load` phase
40
+ def verify!(phase = :load, config = nil)
41
+ raise(Errors::UnsupportedCaseError, phase) unless PHASES.include?(phase)
42
+
43
+ constraints[phase].each_value { |constraint| constraint.call(config) }
26
44
  end
27
45
 
28
46
  private
29
47
 
48
+ # @return [Hash{Symbol => Hash{Symbol => Proc}}] registered constraints per phase
49
+ def constraints
50
+ @constraints ||= PHASES.to_h { |phase| [phase, {}] }
51
+ end
52
+
30
53
  # Requires given version file from a gem location
31
54
  # @param version_location [String]
32
55
  # @return [Boolean] true if it was required or false if not reachable
@@ -45,5 +68,22 @@ module Karafka
45
68
  Gem::Version.new(string)
46
69
  end
47
70
  end
71
+
72
+ # If Karafka is used with the Web UI, it needs to be in a version compatible with the API
73
+ # changes in this karafka version. We can make Web UI require a certain karafka version
74
+ # range, but at the moment we do not have a strict 1:1 release pattern matching those two.
75
+ register(:karafka_web_version, phase: :load) do |_config|
76
+ # Skip verification if web is not used at all
77
+ next unless require_version("karafka/web")
78
+
79
+ # All good if version higher than 1.0.0.rc1 because we expect 1.0.0.rc2 or higher
80
+ next if version(Karafka::Web::VERSION) >= version("1.0.0.rc2")
81
+
82
+ # If older web-ui used, we cannot allow it
83
+ raise(
84
+ Errors::DependencyConstraintsError,
85
+ "karafka-web < 1.0.0.rc2 is not compatible with this karafka version"
86
+ )
87
+ end
48
88
  end
49
89
  end
@@ -16,18 +16,27 @@ module Karafka
16
16
  keyword_init: true
17
17
  ) do
18
18
  # @return [Object] deserialized key. By default in the raw string format.
19
+ # @note The result is cached after the first deserialization, including `nil` and other
20
+ # falsy results (e.g. keyless messages), hence the explicit flag instead of a
21
+ # truthiness check. As with `Message#payload`, the flag is set only after a successful
22
+ # deserialization, so an error is not cached and the next access retries.
19
23
  def key
20
- return @key if @key
24
+ return @key if @key_deserialized
21
25
 
22
26
  @key = deserializers.key.call(self)
27
+ @key_deserialized = true
28
+ @key
23
29
  end
24
30
 
25
31
  # @return [Object] deserialized headers. By default its a hash with keys and payload being
26
32
  # strings
33
+ # @note Caching works as in {#key}, including falsy results
27
34
  def headers
28
- return @headers if @headers
35
+ return @headers if @headers_deserialized
29
36
 
30
37
  @headers = deserializers.headers.call(self)
38
+ @headers_deserialized = true
39
+ @headers
31
40
  end
32
41
  end
33
42
  end
@@ -32,47 +32,77 @@ module Karafka
32
32
  module Pro
33
33
  module Encryption
34
34
  # Cipher for encrypting and decrypting data
35
+ #
36
+ # A facade over the cipher implementations in {Ciphers}:
37
+ #
38
+ # - {Ciphers::Direct} (default) - legacy scheme with the payload RSA-encrypted directly,
39
+ # limited to payloads smaller than the RSA key capacity
40
+ # - {Ciphers::Envelope} - hybrid scheme with a one-time AES-256-GCM key per payload,
41
+ # supporting payloads of any size
42
+ #
43
+ # Encryption follows the `encryption.mode` setting. Decryption is mode-independent: the
44
+ # format is recognized per message (a valid direct RSA ciphertext is always exactly the
45
+ # key modulus size, while an envelope is always at least 29 bytes longer), so consumers
46
+ # decrypt both formats transparently regardless of the configured mode, making staged
47
+ # producer-side rollout of the envelope mode safe.
48
+ #
49
+ # Format detection is deliberately payload-based rather than header-based, even though
50
+ # the encryption middleware already writes message headers that could carry a format
51
+ # marker. A header marker would remove the truncation blind spot documented on
52
+ # {Ciphers::Direct#owns?}, but the payload would stop being self-describing: it must
53
+ # remain decryptable also when it leaves Kafka through channels that do not preserve
54
+ # headers (mirroring and replication tools, dumps, storage sinks) and when handled by
55
+ # custom parsers or ciphers that only receive the payload through the stable
56
+ # `#decrypt(version, content)` contract. We accept the blind spot as the cheaper cost.
35
57
  class Cipher
36
58
  include Helpers::ConfigImporter.new(
37
59
  encryption: %i[encryption]
38
60
  )
39
61
 
40
- # Initializes the cipher with empty private keys cache
41
- def initialize
42
- @private_pems = {}
43
- end
44
-
45
- # Encrypts given string content with the public key
62
+ # Encrypts given string content according to the configured `encryption.mode`
46
63
  # @param content [String]
47
64
  # @return [String]
48
65
  def encrypt(content)
49
- public_pem.public_encrypt(content)
66
+ (encryption.mode == :envelope) ? envelope.encrypt(content) : direct.encrypt(content)
50
67
  end
51
68
 
52
- # Decrypts provided content using `version` key
69
+ # Decrypts provided content using `version` key with the cipher implementation that
70
+ # recognizes the content format, independently of the configured mode
53
71
  # @param version [String] encryption version
54
72
  # @param content [String] encrypted content
55
73
  # @return [String] decrypted content
56
74
  def decrypt(version, content)
57
- private_pem(version).private_decrypt(content)
75
+ if direct.owns?(version, content)
76
+ direct.decrypt(version, content)
77
+ else
78
+ envelope.decrypt(version, content)
79
+ end
58
80
  end
59
81
 
60
- private
61
-
62
- # @return [::OpenSSL::PKey::RSA] rsa public key
63
- def public_pem
64
- @public_pem ||= OpenSSL::PKey::RSA.new(encryption.public_key)
82
+ # Eagerly builds the underlying ciphers and parses the key material of the given
83
+ # config. Invoked during the single-threaded setup phase so that runtime encryption
84
+ # and decryption only read already-built, effectively frozen state and the lazy
85
+ # initialization below never races across worker threads.
86
+ #
87
+ # @param root_config [Karafka::Core::Configurable::Node] config whose key material to
88
+ # warm. During setup this is the same app config the ciphers read at runtime.
89
+ def warmup(root_config)
90
+ direct.warmup(root_config.encryption)
91
+ envelope.warmup(root_config.encryption)
65
92
  end
66
93
 
67
- # @param version [String] version for which we want to get the rsa key
68
- # @return [::OpenSSL::PKey::RSA] rsa private key
69
- def private_pem(version)
70
- return @private_pems[version] if @private_pems.key?(version)
94
+ private
71
95
 
72
- key_string = encryption.private_keys[version]
73
- key_string || raise(Errors::PrivateKeyNotFoundError, version)
96
+ # Lazily built so this facade can be instantiated as a config default while the cipher
97
+ # implementation files may not be loaded yet
98
+ # @return [Ciphers::Direct]
99
+ def direct
100
+ @direct ||= Ciphers::Direct.new
101
+ end
74
102
 
75
- @private_pems[version] = OpenSSL::PKey::RSA.new(key_string)
103
+ # @return [Ciphers::Envelope]
104
+ def envelope
105
+ @envelope ||= Ciphers::Envelope.new
76
106
  end
77
107
  end
78
108
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Karafka Pro - Source Available Commercial Software
4
+ # Copyright (c) 2017-present Maciej Mensfeld. All rights reserved.
5
+ #
6
+ # This software is NOT open source. It is source-available commercial software
7
+ # requiring a paid license for use. It is NOT covered by LGPL.
8
+ #
9
+ # The author retains all right, title, and interest in this software,
10
+ # including all copyrights, patents, and other intellectual property rights.
11
+ # No patent rights are granted under this license.
12
+ #
13
+ # PROHIBITED:
14
+ # - Use without a valid commercial license
15
+ # - Redistribution, modification, or derivative works without authorization
16
+ # - Reverse engineering, decompilation, or disassembly of this software
17
+ # - Use as training data for AI/ML models or inclusion in datasets
18
+ # - Scraping, crawling, or automated collection for any purpose
19
+ #
20
+ # PERMITTED:
21
+ # - Reading, referencing, and linking for personal or commercial use
22
+ # - Runtime retrieval by AI assistants, coding agents, and RAG systems
23
+ # for the purpose of providing contextual help to Karafka users
24
+ #
25
+ # Receipt, viewing, or possession of this software does not convey or
26
+ # imply any license or right beyond those expressly stated above.
27
+ #
28
+ # License: https://karafka.io/docs/Pro-License-Comm/
29
+ # Contact: contact@karafka.io
30
+
31
+ module Karafka
32
+ module Pro
33
+ module Encryption
34
+ # Namespace for the cipher implementations behind the encryption modes
35
+ module Ciphers
36
+ # Base for the cipher implementations, providing shared access to the configured RSA
37
+ # key material with per-version private key resolution
38
+ class Base
39
+ include Helpers::ConfigImporter.new(
40
+ encryption: %i[encryption]
41
+ )
42
+
43
+ # Initializes the cipher with an empty private keys cache
44
+ #
45
+ # @note Each cipher instance holds its own tiny cache of parsed pem objects. With two
46
+ # cipher implementations composed by {Encryption::Cipher} this means the material
47
+ # is parsed at most twice per version, which we accept over introducing a shared
48
+ # keyring concept
49
+ #
50
+ # @note The caches are populated via {#warmup} during the single-threaded setup
51
+ # phase, so under normal operations runtime access is read-only. Should a key
52
+ # version appear only at runtime, the lazy population is idempotent and benign
53
+ # under MRI (worst case the same pem is parsed twice)
54
+ def initialize
55
+ @private_pems = {}
56
+ end
57
+
58
+ # Eagerly parses the given encryption config key material into the instance caches
59
+ #
60
+ # @param encryption_config [Karafka::Core::Configurable::Node] encryption config
61
+ # node. During setup it is the same node the lazy readers resolve at runtime,
62
+ # passed explicitly so this method does not silently couple to the global state
63
+ def warmup(encryption_config)
64
+ @public_pem ||= OpenSSL::PKey::RSA.new(encryption_config.public_key)
65
+
66
+ encryption_config.private_keys.each do |version, key|
67
+ @private_pems[version] ||= OpenSSL::PKey::RSA.new(key)
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # @return [::OpenSSL::PKey::RSA] rsa public key
74
+ def public_pem
75
+ @public_pem ||= OpenSSL::PKey::RSA.new(encryption.public_key)
76
+ end
77
+
78
+ # @param version [String] version for which we want to get the rsa key
79
+ # @return [::OpenSSL::PKey::RSA] rsa private key
80
+ def private_pem(version)
81
+ return @private_pems[version] if @private_pems.key?(version)
82
+
83
+ key_string = encryption.private_keys[version]
84
+ key_string || raise(Errors::PrivateKeyNotFoundError, version)
85
+
86
+ @private_pems[version] = OpenSSL::PKey::RSA.new(key_string)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Karafka Pro - Source Available Commercial Software
4
+ # Copyright (c) 2017-present Maciej Mensfeld. All rights reserved.
5
+ #
6
+ # This software is NOT open source. It is source-available commercial software
7
+ # requiring a paid license for use. It is NOT covered by LGPL.
8
+ #
9
+ # The author retains all right, title, and interest in this software,
10
+ # including all copyrights, patents, and other intellectual property rights.
11
+ # No patent rights are granted under this license.
12
+ #
13
+ # PROHIBITED:
14
+ # - Use without a valid commercial license
15
+ # - Redistribution, modification, or derivative works without authorization
16
+ # - Reverse engineering, decompilation, or disassembly of this software
17
+ # - Use as training data for AI/ML models or inclusion in datasets
18
+ # - Scraping, crawling, or automated collection for any purpose
19
+ #
20
+ # PERMITTED:
21
+ # - Reading, referencing, and linking for personal or commercial use
22
+ # - Runtime retrieval by AI assistants, coding agents, and RAG systems
23
+ # for the purpose of providing contextual help to Karafka users
24
+ #
25
+ # Receipt, viewing, or possession of this software does not convey or
26
+ # imply any license or right beyond those expressly stated above.
27
+ #
28
+ # License: https://karafka.io/docs/Pro-License-Comm/
29
+ # Contact: contact@karafka.io
30
+
31
+ module Karafka
32
+ module Pro
33
+ module Encryption
34
+ module Ciphers
35
+ # Legacy cipher where the payload is RSA-encrypted directly with PKCS1 v1.5 padding.
36
+ #
37
+ # RSA can only encrypt data smaller than the key size minus padding (e.g. ~245 bytes
38
+ # for a 2048-bit key, ~501 bytes for a 4096-bit key), so it is unsuitable for larger
39
+ # payloads and remains available only for backwards compatibility with data already
40
+ # encrypted at rest and with fleets not yet fully upgraded.
41
+ class Direct < Base
42
+ # Encrypts given content with the public key
43
+ # @param content [String]
44
+ # @return [String] RSA ciphertext, always exactly the key modulus size
45
+ def encrypt(content)
46
+ public_pem.public_encrypt(content)
47
+ end
48
+
49
+ # Decrypts provided content using the `version` private key
50
+ # @param version [String] encryption version
51
+ # @param content [String] encrypted content
52
+ # @return [String] decrypted content
53
+ def decrypt(version, content)
54
+ private_pem(version).private_decrypt(content)
55
+ end
56
+
57
+ # @param version [String] encryption version
58
+ # @param content [String] encrypted content
59
+ # @return [Boolean] true if the content matches this cipher's format. A valid direct
60
+ # RSA ciphertext is always exactly the key modulus size
61
+ #
62
+ # @note One inherent blind spot: an envelope truncated to exactly the modulus size is
63
+ # indistinguishable from a direct ciphertext. It surfaces as an RSA padding error
64
+ # or - when the PKCS1 v1.5 padding coincidentally validates - as garbage output,
65
+ # never as the envelope diagnostics. The legacy direct format carries no marker
66
+ # that could disambiguate this.
67
+ def owns?(version, content)
68
+ content.bytesize == private_pem(version).n.num_bytes
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Karafka Pro - Source Available Commercial Software
4
+ # Copyright (c) 2017-present Maciej Mensfeld. All rights reserved.
5
+ #
6
+ # This software is NOT open source. It is source-available commercial software
7
+ # requiring a paid license for use. It is NOT covered by LGPL.
8
+ #
9
+ # The author retains all right, title, and interest in this software,
10
+ # including all copyrights, patents, and other intellectual property rights.
11
+ # No patent rights are granted under this license.
12
+ #
13
+ # PROHIBITED:
14
+ # - Use without a valid commercial license
15
+ # - Redistribution, modification, or derivative works without authorization
16
+ # - Reverse engineering, decompilation, or disassembly of this software
17
+ # - Use as training data for AI/ML models or inclusion in datasets
18
+ # - Scraping, crawling, or automated collection for any purpose
19
+ #
20
+ # PERMITTED:
21
+ # - Reading, referencing, and linking for personal or commercial use
22
+ # - Runtime retrieval by AI assistants, coding agents, and RAG systems
23
+ # for the purpose of providing contextual help to Karafka users
24
+ #
25
+ # Receipt, viewing, or possession of this software does not convey or
26
+ # imply any license or right beyond those expressly stated above.
27
+ #
28
+ # License: https://karafka.io/docs/Pro-License-Comm/
29
+ # Contact: contact@karafka.io
30
+
31
+ module Karafka
32
+ module Pro
33
+ module Encryption
34
+ module Ciphers
35
+ # Hybrid cipher where each payload is encrypted with a one-time AES-256-GCM key and
36
+ # only that key is RSA-wrapped (OAEP padding). Handles payloads of any size.
37
+ #
38
+ # The GCM auth tag covers the whole envelope (header included), so corruption and
39
+ # truncation are detected reliably. Note this is corruption detection, not
40
+ # authenticity: the RSA public key is distributed to all producers, so any of its
41
+ # holders can construct a valid envelope.
42
+ class Envelope < Base
43
+ # Envelope binary format:
44
+ #
45
+ # [1B format version][RSA-wrapped AES key (modulus size)][12B iv][16B tag][ciphertext]
46
+ #
47
+ # The wrapped key needs no size prefix as OAEP output is always exactly the modulus
48
+ # size of the wrapping key. The version byte allows introducing new envelope layouts
49
+ # (different AEAD, compression, etc.) without falling back to length arithmetic.
50
+ VERSION = "\x01".b.freeze
51
+
52
+ # Number of bytes of the leading format version marker
53
+ VERSION_BYTES = 1
54
+
55
+ # AES cipher used for the envelope payload encryption
56
+ AES = "aes-256-gcm"
57
+
58
+ # Number of bytes of the AES key wrapped inside the envelope. Derived from the
59
+ # cipher so a future AES change cannot silently break the invariant
60
+ KEY_BYTES = OpenSSL::Cipher.new(AES).key_len
61
+
62
+ # Number of bytes of the AES-GCM initialization vector, derived like the key size
63
+ IV_BYTES = OpenSSL::Cipher.new(AES).iv_len
64
+
65
+ # Number of bytes of the AES-GCM authentication tag. This is a choice (GCM supports
66
+ # shorter tags), not a cipher-derived property - 16 is the full, recommended size
67
+ TAG_BYTES = 16
68
+
69
+ # OAEP options for the AES key wrapping. SHA-256 for both MGF1 and the label hash -
70
+ # OAEP does not lean on collision resistance, but the SHA-1 default draws flags from
71
+ # scanners and compliance checklists
72
+ OAEP_OPTIONS = {
73
+ rsa_padding_mode: "oaep",
74
+ rsa_oaep_md: "sha256",
75
+ rsa_mgf1_md: "sha256"
76
+ }.freeze
77
+
78
+ private_constant :VERSION, :VERSION_BYTES, :AES, :KEY_BYTES, :IV_BYTES, :TAG_BYTES,
79
+ :OAEP_OPTIONS
80
+
81
+ # Encrypts content with a one-time AES-256-GCM key and RSA-wraps that key using OAEP
82
+ # padding. Unlike PKCS1 v1.5, OAEP unwrapping with a non-matching key fails reliably
83
+ # instead of occasionally yielding garbage. The GCM tag additionally authenticates
84
+ # the whole envelope header, so any bit flip in the version byte, wrapped key or iv
85
+ # is detected, not only ciphertext corruption.
86
+ #
87
+ # @param content [String] content to encrypt
88
+ # @return [String] binary envelope (see {VERSION} for the format)
89
+ def encrypt(content)
90
+ aes = OpenSSL::Cipher.new(AES).encrypt
91
+ aes_key = aes.random_key
92
+ iv = aes.random_iv
93
+
94
+ wrapped_key = public_pem.encrypt(aes_key, OAEP_OPTIONS)
95
+
96
+ header = VERSION + wrapped_key + iv
97
+ aes.auth_data = header
98
+
99
+ # `Cipher#update` rejects empty input on openssl gem < 3.1 (`data must not be
100
+ # empty`), so empty payloads go straight to `#final`
101
+ ciphertext = content.empty? ? aes.final : aes.update(content) + aes.final
102
+
103
+ header + aes.auth_tag(TAG_BYTES) + ciphertext
104
+ end
105
+
106
+ # Decrypts an envelope produced by {#encrypt}
107
+ # @param version [String] encryption version
108
+ # @param content [String] binary envelope
109
+ # @return [String] decrypted content
110
+ # @note All failure paths stay within the `OpenSSL::PKey` error family: the explicit
111
+ # guards raise `RSAError`, while an OAEP unwrap failure (e.g. non-matching private
112
+ # key) surfaces from the EVP API as its parent `PKeyError`
113
+ def decrypt(version, content)
114
+ content = content.b
115
+
116
+ if content.bytesize < VERSION_BYTES
117
+ raise(OpenSSL::PKey::RSAError, "corrupted or truncated envelope")
118
+ end
119
+
120
+ # Version goes next so future layouts of different sizes are reported as
121
+ # unsupported to older consumers instead of as corrupted
122
+ if content[0, VERSION_BYTES] != VERSION
123
+ raise(OpenSSL::PKey::RSAError, "unsupported envelope version")
124
+ end
125
+
126
+ pem = private_pem(version)
127
+ wrapped_size = pem.n.num_bytes
128
+ header_size = VERSION_BYTES + wrapped_size + IV_BYTES
129
+ min_size = header_size + TAG_BYTES
130
+
131
+ if content.bytesize < min_size
132
+ raise(OpenSSL::PKey::RSAError, "corrupted or truncated envelope")
133
+ end
134
+
135
+ wrapped_key = content[VERSION_BYTES, wrapped_size]
136
+ iv = content[VERSION_BYTES + wrapped_size, IV_BYTES]
137
+ tag = content[header_size, TAG_BYTES]
138
+ ciphertext = content[min_size..]
139
+
140
+ aes_key = pem.decrypt(wrapped_key, OAEP_OPTIONS)
141
+
142
+ # OAEP unwrapping of a foreign envelope fails reliably, but anyone holding the
143
+ # public key can wrap a string of arbitrary length. Without this guard such input
144
+ # would surface as an ArgumentError from the AES key assignment, escaping the
145
+ # OpenSSL error family this method otherwise normalizes to
146
+ unless aes_key.bytesize == KEY_BYTES
147
+ raise(OpenSSL::PKey::RSAError, "invalid envelope key size")
148
+ end
149
+
150
+ aes = OpenSSL::Cipher.new(AES).decrypt
151
+ aes.key = aes_key
152
+ aes.iv = iv
153
+ aes.auth_tag = tag
154
+ aes.auth_data = content[0, header_size]
155
+
156
+ # Same empty-input guard as on the encryption side; `#final` still runs and thus
157
+ # still verifies the auth tag for empty payloads
158
+ ciphertext.empty? ? aes.final : aes.update(ciphertext) + aes.final
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -43,6 +43,7 @@ module Karafka
43
43
 
44
44
  nested(:encryption) do
45
45
  required(:active) { |val| [true, false].include?(val) }
46
+ required(:mode) { |val| %i[envelope direct].include?(val) }
46
47
  required(:version) { |val| val.is_a?(String) && !val.empty? }
47
48
  required(:public_key) { |val| val.is_a?(String) }
48
49
  required(:fingerprinter) { |val| val == false || val.respond_to?(:hexdigest) }
@@ -54,6 +54,28 @@ module Karafka
54
54
  # This allows us to support key rotation
55
55
  setting(:private_keys, default: {})
56
56
 
57
+ # Encryption mode used when producing messages:
58
+ #
59
+ # - `:direct` (default) - payload is RSA-encrypted directly, which limits it to the
60
+ # RSA key capacity (key size minus padding, ~245 bytes for 2048-bit keys). Default
61
+ # for backwards compatibility with already running deployments. The default will
62
+ # switch to `:envelope` in a future release (with prior notice); decryption of the
63
+ # `:direct` format is never planned for removal, as data at rest never expires.
64
+ # - `:envelope` - payload is encrypted with a one-time AES-256-GCM key and only that
65
+ # key is RSA-wrapped, so payloads of any size are supported and the GCM auth tag
66
+ # detects corruption and truncation. Note this is not authenticity: the public key
67
+ # is distributed to all producers, so any of its holders can build a valid envelope
68
+ #
69
+ # Decryption always supports both formats regardless of this setting. Since processes
70
+ # older than the version that introduced this setting cannot decrypt envelope
71
+ # payloads, when enabling `:envelope` upgrade all consuming processes first and only
72
+ # then switch producers to the envelope mode.
73
+ #
74
+ # The envelope openssl gem requirement (>= 3.0) is verified during setup. Flipping
75
+ # this setting to `:envelope` at runtime on an unsupported openssl bypasses that
76
+ # friendly boot error and fails on first use instead.
77
+ setting(:mode, default: :direct)
78
+
57
79
  # Cipher used to encrypt and decrypt data
58
80
  setting(:cipher, default: Encryption::Cipher.new)
59
81
 
@@ -43,6 +43,12 @@ module Karafka
43
43
  config.instance_eval do
44
44
  setting(:encryption, default: Setup::Config.config)
45
45
  end
46
+
47
+ # Registered as a config-phase constraint (verified centrally during setup together
48
+ # with all other environment requirements) instead of being checked ad hoc here
49
+ Karafka::Constraints.register(:pro_encryption_envelope_openssl, phase: :config) do |cfg|
50
+ verify_envelope_requirements!(cfg)
51
+ end
46
52
  end
47
53
 
48
54
  # @param config [Karafka::Core::Configurable::Node] root node config
@@ -60,6 +66,13 @@ module Karafka
60
66
 
61
67
  # Encryption for WaterDrop
62
68
  config.producer.middleware.append(Messages::Middleware.new)
69
+
70
+ # Warm the cipher internals (sub-ciphers, parsed key material) in this
71
+ # single-threaded phase so runtime encryption and decryption across worker threads
72
+ # only read already-built state. Custom ciphers can opt in by exposing
73
+ # #warmup(config)
74
+ cipher = config.encryption.cipher
75
+ cipher.warmup(config) if cipher.respond_to?(:warmup)
63
76
  end
64
77
 
65
78
  # This feature does not need any changes post-fork
@@ -69,6 +82,27 @@ module Karafka
69
82
  def post_fork(_config, _pre_fork_producer)
70
83
  true
71
84
  end
85
+
86
+ private
87
+
88
+ # The envelope mode relies on the EVP PKey API (`PKey#encrypt`/`#decrypt` with an
89
+ # options hash), available since the openssl gem 3.0. All supported Rubies bundle a
90
+ # sufficient version as a default gem, but it can be pinned lower in a Gemfile, hence
91
+ # this runtime constraint instead of a gemspec dependency that everyone would carry
92
+ # for a single opt-in feature.
93
+ #
94
+ # @param config [Karafka::Core::Configurable::Node] root node config
95
+ def verify_envelope_requirements!(config)
96
+ return unless config.encryption.active
97
+ return unless config.encryption.mode == :envelope
98
+ return if Gem::Version.new(OpenSSL::VERSION) >= Gem::Version.new("3.0.0")
99
+
100
+ raise(
101
+ Karafka::Errors::DependencyConstraintsError,
102
+ "encryption.mode = :envelope requires the openssl gem >= 3.0, " \
103
+ "#{OpenSSL::VERSION} detected"
104
+ )
105
+ end
72
106
  end
73
107
  end
74
108
  end
@@ -128,6 +128,11 @@ module Karafka
128
128
  # and the whole iteration becomes a silent no-op.
129
129
  @stopped_partitions = Set.new
130
130
  @stopped = false
131
+ # Reset the stored-offsets latch so it reflects only the current run's marking activity.
132
+ # Otherwise a single `mark_as_consumed` would keep firing a spurious blocking sync commit on
133
+ # the teardown of every subsequent `#each`, each of which runs on a brand-new consumer that
134
+ # has no stored offsets.
135
+ @stored_offsets = false
131
136
  end
132
137
 
133
138
  # Stops the partition we're currently yielded into
@@ -262,7 +262,7 @@ module Karafka
262
262
  # Use custom topic if it was returned from the strategy
263
263
  @_dispatch_to_dlq_topic = target_topic || topic.dead_letter_queue.topic
264
264
  else
265
- raise Karafka::UnsupportedCaseError, flow
265
+ raise Karafka::Errors::UnsupportedCaseError, flow
266
266
  end
267
267
 
268
268
  yield
@@ -97,7 +97,7 @@ module Karafka
97
97
  # Use custom topic if it was returned from the strategy
98
98
  @_dispatch_to_dlq_topic = target_topic || topic.dead_letter_queue.topic
99
99
  else
100
- raise Karafka::UnsupportedCaseError, flow
100
+ raise Karafka::Errors::UnsupportedCaseError, flow
101
101
  end
102
102
 
103
103
  yield
@@ -130,7 +130,7 @@ module Karafka
130
130
  pause(throttle_message.offset, throttle_timeout, false)
131
131
  end
132
132
  else
133
- raise Karafka::Errors::UnsupportedCaseError filter.action
133
+ raise Karafka::Errors::UnsupportedCaseError, filter.action
134
134
  end
135
135
  end
136
136
  end
@@ -90,12 +90,14 @@ module Karafka
90
90
  # @param job [Jobs::Base] job that locked the queue
91
91
  def unlock(job)
92
92
  @mutex.synchronize do
93
- @statistics[:waiting] -= 1
93
+ if @in_waiting[job.group_id].delete(job)
94
+ @statistics[:waiting] -= 1
94
95
 
95
- return if @in_waiting[job.group_id].delete(job)
96
+ return
97
+ end
96
98
 
97
99
  # This should never happen. It means there was a job being unlocked that was never
98
- # locked in the first place
100
+ # locked in the first place (or its group was already reset via `#clear`)
99
101
  raise(Errors::JobsQueueSynchronizationError, job.group_id)
100
102
  end
101
103
  end
@@ -185,6 +187,14 @@ module Karafka
185
187
  # Thanks to the fact that we use the minimum lock time as a timeout, we do not have to
186
188
  # wait a whole ticking period to unlock async locks.
187
189
  def wait(group_id)
190
+ # Drain stale `#tick` signals up front: for non-blocking/LRJ jobs and released async
191
+ # locks (both Pro-only) `#wait?` can be false from the start, so the loop below - the
192
+ # only other consumer - never runs and signals would otherwise accumulate unbounded.
193
+ # Safe without locking as `#wait` runs only on the single listener thread that owns
194
+ # (and has already registered) this group.
195
+ semaphore = @semaphores.fetch(group_id)
196
+ semaphore.pop(timeout: 0) until semaphore.empty?
197
+
188
198
  return super unless @async_locking
189
199
 
190
200
  # We do not generalize this flow because this one is more expensive as it has to allocate
@@ -79,11 +79,34 @@ module Karafka
79
79
  def install(pattern, discovered_topic, sg_topics)
80
80
  group = pattern.topic.group
81
81
 
82
- # Build new topic and register within the owning group
83
- topic = group.public_send(:topic=, discovered_topic, &pattern.config)
84
- topic.patterns(active: true, type: :discovered)
82
+ # Under multiplexing several subscription groups share the same consumer group but
83
+ # each runs its own listener thread and independently discovers the same topic.
84
+ # ConsumerGroup#topic= appends unconditionally, so without this guard the shared
85
+ # group would accumulate up to `multiplex_factor` duplicate Topic objects per
86
+ # discovered topic, polluting the routing tree. We run under the process-wide
87
+ # MUTEX (see #expand), so the find + register below is atomic across subscription
88
+ # groups: the discovered topic is registered in the shared group only once.
89
+ topic = group.topics.detect { |existing| existing.name == discovered_topic }
85
90
 
86
- # Assign the appropriate subscription group to this topic
91
+ if topic
92
+ # A sibling multiplexed subscription group already registered this topic in the
93
+ # shared consumer group. Each subscription group must still hold its OWN Topic
94
+ # instance in its topics array: the Topic doubles as a per-subscription-group
95
+ # key (Instrumentation::AssignmentsTracker keys assignments on the instance and
96
+ # reads topic.subscription_group.id), so reusing one instance across
97
+ # subscription groups would collide their assignments. We inject a
98
+ # per-subscription-group copy, mirroring how the multiplexing subscription
99
+ # groups builder dups topics per group at boot.
100
+ topic = topic.dup
101
+ else
102
+ # Build new topic and register within the owning group
103
+ topic = group.public_send(:topic=, discovered_topic, &pattern.config)
104
+ topic.patterns(active: true, type: :discovered)
105
+ end
106
+
107
+ # Assign the appropriate subscription group to this topic. Each subscription group
108
+ # owns its own Topic instance (freshly built or dup'd above), so this never leaks
109
+ # across multiplexed subscription groups.
87
110
  topic.subscription_group = pattern.topic.subscription_group
88
111
 
89
112
  # Inject into subscription group topics array always, so everything is reflected
@@ -121,22 +121,19 @@ module Karafka
121
121
  # that all tombstone events are loaded not to duplicate dispatches
122
122
  return unless @state.loaded?
123
123
 
124
- keys = []
125
-
126
- # We first collect all the data for dispatch and then dispatch and **only** after
127
- # dispatch that is sync is successful we remove those messages from the daily buffer
128
- # and update the max epoch. Since only the dispatch itself is volatile and can crash
129
- # with timeouts, etc, we need to be sure it wen through prior to deleting those messages
130
- # from the daily buffer. That way we ensure the at least once delivery and in case of
131
- # a transactional producer, exactly once delivery.
124
+ # We first collect all the data for dispatch and then dispatch it in sync batches. Each
125
+ # key is removed from the daily buffer only once the batch carrying it is confirmed
126
+ # delivered - not after the whole flush - so a broker error partway through does not
127
+ # leave already-produced batches in the buffer to be re-dispatched on the next tick
128
+ # (a duplicate-dispatch window for non-transactional producers). This keeps the
129
+ # at-least-once guarantee, and exactly-once when a transactional producer is used.
132
130
  @daily_buffer.for_dispatch do |message|
133
- keys << message.key
134
131
  @dispatcher << message
135
132
  end
136
133
 
137
- @dispatcher.flush
138
-
139
- keys.each { |key| @daily_buffer.delete(key) }
134
+ @dispatcher.flush do |dispatched_keys|
135
+ dispatched_keys.each { |key| @daily_buffer.delete(key) }
136
+ end
140
137
 
141
138
  @states_reporter.call
142
139
  end
@@ -58,6 +58,15 @@ module Karafka
58
58
  schedule_source_type
59
59
  ].freeze
60
60
 
61
+ # Upper bound (in seconds) for how far ahead a schedule may target: 100 years.
62
+ # schedule_target_epoch is Unix time in seconds; anything beyond this is almost certainly
63
+ # a unit mistake (for example milliseconds passed as seconds, ~1000x too large). We reject
64
+ # it here so it fails loudly at publish time instead of being silently dropped by the
65
+ # consumer.
66
+ MAX_FUTURE_INTERVAL = 100 * 365 * 24 * 60 * 60
67
+
68
+ private_constant :MAX_FUTURE_INTERVAL
69
+
61
70
  required(:key) { |val| val.is_a?(String) && val.size.positive? }
62
71
 
63
72
  # Ensure that schedule has all correct keys and that others have other related data
@@ -84,9 +93,15 @@ module Karafka
84
93
 
85
94
  # We allow for small lag as those will be dispatched but we should prevent dispatching
86
95
  # in the past in general as often it is a source of errors
87
- next if epoch_time >= Time.now.to_i - 10
96
+ if epoch_time < Time.now.to_i - 10
97
+ next [[[:headers], :schedule_target_epoch_in_the_past]]
98
+ end
99
+
100
+ # Reject epochs implausibly far ahead (e.g. milliseconds passed as seconds), which would
101
+ # otherwise validate here and then be silently dropped by the consumer, never scheduled
102
+ next if epoch_time <= Time.now.to_i + MAX_FUTURE_INTERVAL
88
103
 
89
- [[[:headers], :schedule_target_epoch_in_the_past]]
104
+ [[[:headers], :schedule_target_epoch_too_far_in_future]]
90
105
  end
91
106
 
92
107
  # Makes sure, that the target envelope topic we dispatch to is a scheduled messages topic
@@ -46,6 +46,10 @@ module Karafka
46
46
  @topic = topic
47
47
  @partition = partition
48
48
  @buffer = []
49
+ # Source (daily buffer) key aligned 1:1 with each `@buffer` entry, so `#flush` can report
50
+ # which keys were confirmed delivered per chunk and the consumer can evict them
51
+ # incrementally instead of only after the whole flush succeeds
52
+ @keys = []
49
53
  @serializer = Serializer.new
50
54
  end
51
55
 
@@ -78,12 +82,21 @@ module Karafka
78
82
  extract(target, message.headers, :key)
79
83
  extract(target, message.headers, :partition_key)
80
84
 
85
+ # `message.key` is pushed to `@keys` once per `@buffer` entry (here and below), not once
86
+ # per message: `@keys` must stay 1:1 aligned with `@buffer` (see `#flush`) so that
87
+ # shifting a chunk off both arrays together always yields the correct keys for that
88
+ # chunk. The target and its tombstone are two separate `@buffer` entries for the same
89
+ # schedule, so the same key is intentionally paired with each. `DailyBuffer#delete` is a
90
+ # plain `Hash#delete`, so a key evicted twice within the same yielded chunk (once per
91
+ # entry) is a harmless no-op the second time.
81
92
  @buffer << target
93
+ @keys << message.key
82
94
 
83
95
  # Tombstone message so this schedule is no longer in use and gets removed from Kafka by
84
96
  # Kafka itself during compacting. It will not cancel it because already dispatched but
85
97
  # will cause it not to be sent again and will be marked as dispatched.
86
98
  @buffer << Proxy.tombstone(message: message)
99
+ @keys << message.key
87
100
  end
88
101
 
89
102
  # Builds and dispatches the state report message with schedules details
@@ -105,14 +118,42 @@ module Karafka
105
118
  # Sends all messages to Kafka in a sync way.
106
119
  # We use sync with batches to prevent overloading.
107
120
  # When transactional producer in use, this will be wrapped in a transaction automatically.
121
+ #
122
+ # @yieldparam [Array<String>] keys of the chunk that was just confirmed delivered. Yielded
123
+ # after each chunk's sync produce returns, so the caller can evict those keys from the
124
+ # daily buffer incrementally. If a later chunk raises, the chunks already produced have
125
+ # still been reported, so a non-transactional producer will not re-dispatch them.
126
+ # @raise [ArgumentError] when called without a block, since a caller that cannot observe
127
+ # per-chunk confirmations cannot evict incrementally and would reintroduce the
128
+ # whole-flush-or-nothing duplicate-dispatch window this method exists to close
108
129
  def flush
130
+ raise ArgumentError, "#flush requires a block to report per-chunk confirmations" unless block_given?
131
+
109
132
  until @buffer.empty?
110
- config.producer.produce_many_sync(
111
- # We can remove this prior to the dispatch because we only evict messages from the
112
- # daily buffer once dispatch is successful
113
- @buffer.shift(config.flush_batch_size)
114
- )
133
+ batch_size = config.flush_batch_size
134
+
135
+ # A message's target and its tombstone are always buffered as an adjacent pair (see
136
+ # `#<<`). Rounding the chunk size up to even guarantees a chunk boundary can never
137
+ # fall between them - with an odd (or 1) `flush_batch_size`, a chunk could otherwise
138
+ # confirm and yield a key whose target was produced but whose tombstone was not, and a
139
+ # later chunk failing would then leave that schedule non-tombstoned in Kafka, to be
140
+ # re-dispatched after a restart/reload.
141
+ batch_size += 1 if batch_size.odd?
142
+
143
+ messages = @buffer.shift(batch_size)
144
+ keys = @keys.shift(batch_size)
145
+
146
+ config.producer.produce_many_sync(messages)
147
+
148
+ yield(keys)
115
149
  end
150
+ ensure
151
+ # Whether flush finished normally (buffer already empty here, so this is a no-op) or
152
+ # raised partway through, drop anything left. Those messages are still in the daily
153
+ # buffer (their keys were never yielded, so never evicted) and will be re-buffered on
154
+ # the next tick, so stale leftovers here must not be dispatched a second time.
155
+ @buffer.clear
156
+ @keys.clear
116
157
  end
117
158
 
118
159
  private
@@ -48,10 +48,11 @@ module Karafka
48
48
  def revoke(topic_name, partition)
49
49
  return unless @coordinators[topic_name].key?(partition)
50
50
 
51
- # Reset the partition's pause tracker attempt count. The tracker lives in the pauses
52
- # manager keyed by topic-partition and would otherwise be reused as-is if we reclaim this
53
- # partition, carrying a stale retry attempt count across the rebalance (which, with DLQ,
54
- # would send the next failure straight to the dead letter queue, skipping the retries)
51
+ # Reset (or, if not currently paused, remove) the partition's pause tracker. This
52
+ # prevents a stale retry attempt count from being reused as-is if we reclaim this
53
+ # partition (which, with DLQ, would send the next failure straight to the dead letter
54
+ # queue, skipping the retries) while also keeping `PausesManager#@pauses` from growing
55
+ # unbounded. See `PausesManager#revoke` for the full reasoning
55
56
  @pauses_manager.revoke(@topics.find(topic_name), partition)
56
57
 
57
58
  # The fact that we delete here does not change the fact that the executor still holds the
@@ -115,8 +115,11 @@ module Karafka
115
115
 
116
116
  # We check from time to time (for the timeout period) if all the threads finished
117
117
  # their work and if so, we can just return and normal shutdown process will take place
118
- # We divide it by 1000 because we use time in ms.
119
- ((timeout / 1_000) * (1 / supervision_sleep)).to_i.times do
118
+ # We use float math and divide by 1000 (ms -> s) so that sub-second `shutdown_timeout`
119
+ # values do not collapse to zero iterations (integer division would floor them to 0,
120
+ # skipping the grace period entirely and forcing an immediate forceful shutdown). We
121
+ # `ceil` to guarantee at least one supervision check for any positive timeout.
122
+ (timeout / (supervision_sleep * 1_000)).ceil.times do
120
123
  all_listeners_stopped = listeners.all?(&:stopped?)
121
124
  all_workers_stopped = workers.stopped?
122
125
 
@@ -430,12 +430,12 @@ module Karafka
430
430
  scope: %w[config]
431
431
  )
432
432
 
433
- configure_components(proxy)
433
+ # Verify config-dependent constraints (registered by features for their optional
434
+ # environment requirements) once the config shape is guaranteed by the contract but
435
+ # before any components spin up
436
+ Karafka::Constraints.verify!(:config, config)
434
437
 
435
- # Install backwards-compatible forwarding so that gems (e.g. karafka-testing) that
436
- # still access config.internal.processing.strategy_selector (etc.) keep working after
437
- # the move to config.internal.processing.consumer_groups.*
438
- install_processing_cg_forwarders(config)
438
+ configure_components(proxy)
439
439
 
440
440
  # Refreshes the references that are cached that might have been changed by the config
441
441
  Karafka.refresh!
@@ -460,38 +460,6 @@ module Karafka
460
460
 
461
461
  private
462
462
 
463
- # Installs forwarding reader methods on the processing config node so that the old
464
- # (pre-nesting) paths like `config.internal.processing.strategy_selector` still resolve
465
- # by delegating to `config.internal.processing.consumer_groups.strategy_selector`.
466
- # This keeps external gems (e.g. karafka-testing) working until they migrate.
467
- #
468
- # @param config [Karafka::Core::Configurable::Node] root config node
469
- def install_processing_cg_forwarders(config)
470
- processing = config.internal.processing
471
- cg_node = processing.consumer_groups
472
-
473
- %i[
474
- jobs_builder
475
- coordinator_class
476
- errors_tracker_class
477
- partitioner_class
478
- strategy_selector
479
- expansions_selector
480
- executor_class
481
- ].each do |setting_name|
482
- writer = :"#{setting_name}="
483
-
484
- # Remove previous definitions (if setup runs more than once) to avoid
485
- # "method redefined" warnings that spec_helper promotes to errors
486
- sc = processing.singleton_class
487
- sc.remove_method(setting_name) if processing.respond_to?(setting_name)
488
- sc.remove_method(writer) if processing.respond_to?(writer)
489
-
490
- processing.define_singleton_method(setting_name) { cg_node.public_send(setting_name) }
491
- processing.define_singleton_method(writer) { |val| cg_node.public_send(writer, val) }
492
- end
493
- end
494
-
495
463
  # Sets up all the components that are based on the user configuration
496
464
  # @param config_proxy [ConfigProxy] the configuration proxy containing deferred setup
497
465
  # blocks
@@ -190,6 +190,8 @@ module Karafka
190
190
  # @param signal [String]
191
191
  # @return [Boolean] true if signal was sent, false if process doesn't exist
192
192
  def signal(signal)
193
+ @mutex.synchronize { return false if @alive == false }
194
+
193
195
  ::Process.kill(signal, @pid)
194
196
  true
195
197
  rescue Errno::ESRCH
@@ -143,8 +143,10 @@ module Karafka
143
143
 
144
144
  # We check from time to time (for the timeout period) if all the threads finished
145
145
  # their work and if so, we can just return and normal shutdown process will take place
146
- # We divide it by 1000 because we use time in ms.
147
- ((total_shutdown_timeout / 1_000) * (1 / supervision_sleep)).to_i.times do
146
+ # We use float math and divide by 1000 (ms -> s) so that sub-second timeout values do not
147
+ # collapse to zero iterations via integer division. We `ceil` to guarantee at least one
148
+ # supervision check for any positive timeout.
149
+ (total_shutdown_timeout / (supervision_sleep * 1_000)).ceil.times do
148
150
  if manager.stopped?
149
151
  manager.cleanup
150
152
  return
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Karafka
4
4
  # Current Karafka version
5
- VERSION = "2.6.0"
5
+ VERSION = "2.6.1"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: karafka
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.6.0
4
+ version: 2.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Mensfeld
@@ -234,6 +234,9 @@ files:
234
234
  - lib/karafka/pro/contracts/base.rb
235
235
  - lib/karafka/pro/encryption.rb
236
236
  - lib/karafka/pro/encryption/cipher.rb
237
+ - lib/karafka/pro/encryption/ciphers/base.rb
238
+ - lib/karafka/pro/encryption/ciphers/direct.rb
239
+ - lib/karafka/pro/encryption/ciphers/envelope.rb
237
240
  - lib/karafka/pro/encryption/contracts/config.rb
238
241
  - lib/karafka/pro/encryption/errors.rb
239
242
  - lib/karafka/pro/encryption/messages/middleware.rb