waterdrop 2.10.2 → 2.10.3
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 +4 -4
- data/CHANGELOG.md +3 -0
- data/Gemfile.lock +8 -17
- data/bin/verify_kafka_warnings +12 -0
- data/docker-compose.oauth.yml +1 -1
- data/docker-compose.sasl.yml +1 -1
- data/docker-compose.yml +1 -1
- data/lib/waterdrop/config.rb +29 -0
- data/lib/waterdrop/contracts/config.rb +1 -0
- data/lib/waterdrop/producer/transactions.rb +73 -3
- data/lib/waterdrop/producer.rb +7 -0
- data/lib/waterdrop/version.rb +1 -1
- data/package-lock.json +3 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec39a827b8ea9216ef39589a8ce2c8261ce29b5ba1386ddb513c2ca393b5ade1
|
|
4
|
+
data.tar.gz: d37a7617f72d101f75c5d02f4c77627757ad5254322ecbf8bf1c30b0b502fb98
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e65469f5af00b6976f8d6bcc7efecbb8bc7ce3e84418928d6364197c2b84c600730bb36cfa0ffd2333ad7a726367406aea69fa56d9ecc796b1a31a8b5cb11f94
|
|
7
|
+
data.tar.gz: 654c121690450afdbb356527cbfb142bacc0b7eddbd3dd0c69017d5564cbdeb0712647cd8b13c27180a7cc94a23cca4049d748935e0ca6ccef068f5f55c079d7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# WaterDrop changelog
|
|
2
2
|
|
|
3
|
+
## 2.10.3 (2026-07-15)
|
|
4
|
+
- [Feature] Add `wait_timeout_on_transaction_abort` (default `0`, disabled) - an opt-in mitigation for [librdkafka#4849](https://github.com/confluentinc/librdkafka/issues/4849). librdkafka only marks a transaction as ongoing at the coordinator once the `AddPartitionsToTxn` **response** arrives, but it fires `EndTxn` as soon as that request has merely been **sent**. Aborting while the first produce is still in flight can therefore reach a coordinator that does not yet consider the transaction started, failing the abort with a fatal `INVALID_TXN_STATE` that poisons the client and forces a full reload. When set to a positive value, we wait for the first delivery of the transaction to be acknowledged before aborting, for at most that long (in ms) - it is a ceiling rather than a fixed delay, so the wait ends as soon as the delivery is acknowledged. A single ack proves its partition completed registration, which is enough for the coordinator to accept `EndTxn` regardless of how many partitions the transaction spans. It is off by default because it changes abort semantics: the awaited message is really written to the log (aborted, hence invisible to `read_committed` consumers) instead of being purged, so its delivery handle reports a real offset rather than a `Purged in queue` error and no `message.purged` is emitted for it. The wait is bounded and best-effort - if it expires we abort exactly as before, and the fatal remains recoverable through `reload_on_transaction_fatal_error`.
|
|
5
|
+
|
|
3
6
|
## 2.10.2 (2026-06-15)
|
|
4
7
|
- [Feature] Expose `Producer#current_variant` as a public method. It returns the variant active for the current dispatch on the current fiber - the custom variant while inside a `#with`/`#variant`-wrapped call, otherwise the producer's default variant - so middleware and instrumentation listeners running synchronously within a dispatch can read the effective per-dispatch settings (`topic_config`, `max_wait_timeout`, `default?`). The lookup is fiber-local and dispatch-scoped: outside a variant-wrapped call (or from an asynchronous delivery callback) it returns the default variant.
|
|
5
8
|
- [Enhancement] Stop allocating one interpolated string per message in `LoggerListener` batch produce handlers. The quoted topic strings were only ever counted (quoting is a 1:1 mapping), never displayed, so counting the raw topic values yields the identical number with zero string allocations - relevant for large `produce_many_*` batches with the default logger listener attached.
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
waterdrop (2.10.
|
|
4
|
+
waterdrop (2.10.3)
|
|
5
5
|
karafka-core (>= 2.5.12, < 3.0.0)
|
|
6
6
|
karafka-rdkafka (>= 0.24.0)
|
|
7
7
|
zeitwerk (~> 2.3)
|
|
@@ -12,12 +12,11 @@ GEM
|
|
|
12
12
|
byebug (13.0.0)
|
|
13
13
|
reline (>= 0.6.0)
|
|
14
14
|
connection_pool (3.0.2)
|
|
15
|
-
docile (1.4.1)
|
|
16
15
|
drb (2.2.3)
|
|
17
16
|
ffi (1.17.4)
|
|
18
17
|
io-console (0.8.2)
|
|
19
|
-
json (2.
|
|
20
|
-
karafka-core (2.
|
|
18
|
+
json (2.20.0)
|
|
19
|
+
karafka-core (2.6.2)
|
|
21
20
|
karafka-rdkafka (>= 0.20.0)
|
|
22
21
|
logger (>= 1.6.0)
|
|
23
22
|
karafka-rdkafka (0.27.2)
|
|
@@ -39,12 +38,7 @@ GEM
|
|
|
39
38
|
reline (0.6.3)
|
|
40
39
|
io-console (~> 0.5)
|
|
41
40
|
ruby2_keywords (0.0.5)
|
|
42
|
-
simplecov (0.
|
|
43
|
-
docile (~> 1.1)
|
|
44
|
-
simplecov-html (~> 0.11)
|
|
45
|
-
simplecov_json_formatter (~> 0.1)
|
|
46
|
-
simplecov-html (0.13.2)
|
|
47
|
-
simplecov_json_formatter (0.1.4)
|
|
41
|
+
simplecov (1.0.0)
|
|
48
42
|
warning (1.6.0)
|
|
49
43
|
zeitwerk (2.8.2)
|
|
50
44
|
|
|
@@ -65,12 +59,11 @@ DEPENDENCIES
|
|
|
65
59
|
CHECKSUMS
|
|
66
60
|
byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d
|
|
67
61
|
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
|
68
|
-
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
|
|
69
62
|
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
|
70
63
|
ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf
|
|
71
64
|
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
|
|
72
|
-
json (2.
|
|
73
|
-
karafka-core (2.
|
|
65
|
+
json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b
|
|
66
|
+
karafka-core (2.6.2) sha256=c2fd7f277201b8ca97b824b364ad76bf776b8f5527bc422dafb71f7ae48d3a13
|
|
74
67
|
karafka-rdkafka (0.27.2) sha256=3ccce96306642be70bff8168e4e737fc10f2ffae20bc0ff0a43d88dbb7452d31
|
|
75
68
|
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
|
76
69
|
mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289
|
|
@@ -81,11 +74,9 @@ CHECKSUMS
|
|
|
81
74
|
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
|
82
75
|
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
|
|
83
76
|
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
|
|
84
|
-
simplecov (0.
|
|
85
|
-
simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
|
|
86
|
-
simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
|
|
77
|
+
simplecov (1.0.0) sha256=bdc50b41fa4a8b3c860da4bf5d61ba4704479c51d08a8f53cbd56ae50778dca8
|
|
87
78
|
warning (1.6.0) sha256=a49cdfae19fb77d19afff2efbe45f8ab759e9cd25b4e4ce2c79dbaf46bdb6c9e
|
|
88
|
-
waterdrop (2.10.
|
|
79
|
+
waterdrop (2.10.3)
|
|
89
80
|
zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12
|
|
90
81
|
|
|
91
82
|
BUNDLED WITH
|
data/bin/verify_kafka_warnings
CHANGED
|
@@ -7,6 +7,18 @@ allowed_patterns=(
|
|
|
7
7
|
"Performing controller activation"
|
|
8
8
|
"registered with feature metadata.version"
|
|
9
9
|
"TOPIC_ALREADY_EXISTS"
|
|
10
|
+
# The transactional_abort_after_commit_race and transactional_abort_race_canary integration specs
|
|
11
|
+
# deliberately reproduce librdkafka#4849: they abort with the first produce still in flight, so
|
|
12
|
+
# the abort's EndTxn can reach the coordinator before AddPartitionsToTxn registers the new
|
|
13
|
+
# transaction. The coordinator then still holds the *previous* transaction's COMPLETE_COMMIT state
|
|
14
|
+
# and rejects the ABORT marker, logging this warning. It is the broker-side signature of the very
|
|
15
|
+
# defect those specs exist to reproduce, so it is expected there - and scoped to their
|
|
16
|
+
# transactional ids so we still catch the same warning anywhere else.
|
|
17
|
+
#
|
|
18
|
+
# One entry per spec rather than an alternation: `\|` is a GNU extension to POSIX BRE, so on BSD
|
|
19
|
+
# grep (macOS) it would match nothing and the allowance would silently do nothing at all.
|
|
20
|
+
"tx-abort-race-id.*received transaction marker result to send: ABORT"
|
|
21
|
+
"tx-abort-canary-id.*received transaction marker result to send: ABORT"
|
|
10
22
|
)
|
|
11
23
|
|
|
12
24
|
# Get all warnings
|
data/docker-compose.oauth.yml
CHANGED
data/docker-compose.sasl.yml
CHANGED
data/docker-compose.yml
CHANGED
data/lib/waterdrop/config.rb
CHANGED
|
@@ -95,6 +95,35 @@ module WaterDrop
|
|
|
95
95
|
# option [Integer] How many times to attempt reloading on transactional fatal error before
|
|
96
96
|
# giving up. This prevents infinite reload loops if the producer never recovers.
|
|
97
97
|
setting :max_attempts_on_transaction_fatal_error, default: 10
|
|
98
|
+
# option [Numeric] How long to wait **at most** (in ms) for the first delivery of a transaction
|
|
99
|
+
# to be acknowledged before we abort that transaction. `0` (the default) disables the wait.
|
|
100
|
+
#
|
|
101
|
+
# This is a ceiling, not a fixed delay: the wait ends as soon as the delivery is acknowledged,
|
|
102
|
+
# which under normal conditions is immediate. The full timeout is only ever spent when the
|
|
103
|
+
# delivery never arrives at all.
|
|
104
|
+
#
|
|
105
|
+
# This is an opt-in mitigation for a librdkafka defect. librdkafka only flags a transaction as
|
|
106
|
+
# ongoing at the coordinator once the `AddPartitionsToTxn` **response** arrives, but it sends
|
|
107
|
+
# `EndTxn` as soon as that request has merely been **sent**. Aborting with the first produce
|
|
108
|
+
# still in flight can therefore hit a coordinator that does not consider the transaction
|
|
109
|
+
# started yet, failing the abort with a fatal `INVALID_TXN_STATE`.
|
|
110
|
+
# See https://github.com/confluentinc/librdkafka/issues/4849
|
|
111
|
+
#
|
|
112
|
+
# A single acknowledged delivery proves its partition completed registration, and that alone
|
|
113
|
+
# makes the coordinator accept `EndTxn` - no matter how many partitions the transaction spans.
|
|
114
|
+
# So waiting for one delivery before aborting closes the race.
|
|
115
|
+
#
|
|
116
|
+
# It is off by default because it changes abort semantics: waiting for that ack means the
|
|
117
|
+
# first message is actually **delivered** (aborted, so invisible to `read_committed`
|
|
118
|
+
# consumers) instead of being **purged**. Its delivery handle then reports a real offset
|
|
119
|
+
# rather than a `Purged in queue` error, and no `message.purged` event is emitted for it,
|
|
120
|
+
# while the remaining messages of the same transaction are still purged. Enable this only if
|
|
121
|
+
# you hit the defect and prefer that trade over the fatal (which stays recoverable through
|
|
122
|
+
# `reload_on_transaction_fatal_error` either way).
|
|
123
|
+
#
|
|
124
|
+
# The wait is bounded and best-effort: if it expires (broker down, message timeout) we abort
|
|
125
|
+
# exactly as if it were disabled.
|
|
126
|
+
setting :wait_timeout_on_transaction_abort, default: 0
|
|
98
127
|
# option [Array<Symbol>] List of fatal error codes that should NOT trigger producer reload.
|
|
99
128
|
# These errors represent states that cannot be recovered by simply recreating the client.
|
|
100
129
|
#
|
|
@@ -30,6 +30,7 @@ module WaterDrop
|
|
|
30
30
|
required(:max_attempts_on_idempotent_fatal_error) { |val| val.is_a?(Integer) && val >= 1 }
|
|
31
31
|
required(:wait_backoff_on_transaction_fatal_error) { |val| val.is_a?(Numeric) && val >= 0 }
|
|
32
32
|
required(:max_attempts_on_transaction_fatal_error) { |val| val.is_a?(Integer) && val >= 1 }
|
|
33
|
+
required(:wait_timeout_on_transaction_abort) { |val| val.is_a?(Numeric) && val >= 0 }
|
|
33
34
|
required(:non_reloadable_errors) do |val|
|
|
34
35
|
val.is_a?(Array) && val.all?(Symbol)
|
|
35
36
|
end
|
|
@@ -106,9 +106,11 @@ module WaterDrop
|
|
|
106
106
|
# This is why we catch this here
|
|
107
107
|
begin
|
|
108
108
|
with_transactional_error_handling(:abort) do
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
# Outside of the `aborted` instrumentation on purpose: that event is supposed to
|
|
110
|
+
# measure `client.abort_transaction`, not our wait
|
|
111
|
+
transactional_await_first_delivery(e)
|
|
112
|
+
|
|
113
|
+
transactional_instrument(:aborted) { client.abort_transaction }
|
|
112
114
|
end
|
|
113
115
|
rescue => e
|
|
114
116
|
# If something from rdkafka leaks here, it means there was a non-retryable error that
|
|
@@ -121,6 +123,13 @@ module WaterDrop
|
|
|
121
123
|
transactional_reload_client_if_needed(e)
|
|
122
124
|
|
|
123
125
|
raise unless e.is_a?(WaterDrop::Errors::AbortTransaction)
|
|
126
|
+
ensure
|
|
127
|
+
# The first delivery handle is scoped to the transaction that produced it and is only
|
|
128
|
+
# ever read by the abort path (which runs in the rescue above, before this). Clearing it
|
|
129
|
+
# here - on commit, on abort and on a re-raise alike - keeps it from outliving its
|
|
130
|
+
# transaction: otherwise a committed transaction would leave its handle (and everything
|
|
131
|
+
# the delivery report references) pinned to the producer until the next one begins.
|
|
132
|
+
@transaction_first_handle = nil
|
|
124
133
|
end
|
|
125
134
|
end
|
|
126
135
|
end
|
|
@@ -255,6 +264,12 @@ module WaterDrop
|
|
|
255
264
|
if e.abortable? && allow_abortable
|
|
256
265
|
# Always attempt to abort but if aborting fails with an abortable error, do not attempt
|
|
257
266
|
# to abort from abort as this could create an infinite loop
|
|
267
|
+
#
|
|
268
|
+
# We deliberately do NOT wait for the first delivery here (unlike the abort in
|
|
269
|
+
# `#transaction`): we only get here because librdkafka reported an abortable error, so the
|
|
270
|
+
# transaction is already in an error state where it no longer delivers. The queued messages
|
|
271
|
+
# are purged by the abort itself, so the handle would never resolve and we would just stall
|
|
272
|
+
# for the whole `wait_timeout_on_transaction_abort` on a path that is already recovering.
|
|
258
273
|
with_transactional_error_handling(:abort, allow_abortable: false) do
|
|
259
274
|
transactional_instrument(:aborted) { client.abort_transaction }
|
|
260
275
|
end
|
|
@@ -263,6 +278,61 @@ module WaterDrop
|
|
|
263
278
|
raise
|
|
264
279
|
end
|
|
265
280
|
|
|
281
|
+
# Waits (bounded) for the first delivery of the current transaction before we abort it.
|
|
282
|
+
#
|
|
283
|
+
# librdkafka only marks a transaction as ongoing at the coordinator once the
|
|
284
|
+
# `AddPartitionsToTxn` **response** comes back, but it fires `EndTxn` as soon as that request
|
|
285
|
+
# has merely been **sent** (it gates on `txn_req_cnt`, bumped on send). Aborting with the first
|
|
286
|
+
# produce still in flight can therefore reach a coordinator that does not yet consider the
|
|
287
|
+
# transaction started, which fails the abort with a fatal `INVALID_TXN_STATE`.
|
|
288
|
+
# See https://github.com/confluentinc/librdkafka/issues/4849
|
|
289
|
+
#
|
|
290
|
+
# A delivered message proves its partition completed registration, and that alone puts the
|
|
291
|
+
# transaction in an `ongoing` state at the coordinator - so a single acknowledged delivery is
|
|
292
|
+
# enough to make `EndTxn` valid, regardless of how many partitions the transaction spans.
|
|
293
|
+
#
|
|
294
|
+
# This is best-effort and never fatal: if the delivery does not materialize within the timeout
|
|
295
|
+
# (broker down, message timeout, purge) we abort exactly as before rather than hanging. The
|
|
296
|
+
# fatal, should it still happen, remains recoverable through the client reload.
|
|
297
|
+
#
|
|
298
|
+
# @param error [Exception] the error that is causing us to abort this transaction
|
|
299
|
+
def transactional_await_first_delivery(error)
|
|
300
|
+
handle = @transaction_first_handle
|
|
301
|
+
timeout = config.wait_timeout_on_transaction_abort
|
|
302
|
+
|
|
303
|
+
return if handle.nil?
|
|
304
|
+
return if timeout.zero?
|
|
305
|
+
# Only worth waiting when the transaction is still healthy, that is when we abort because the
|
|
306
|
+
# user asked us to (`AbortTransaction`) or because their own code raised. Once librdkafka
|
|
307
|
+
# itself reported an error, the transaction no longer delivers: the queued messages are
|
|
308
|
+
# purged by the abort, so this handle would never resolve and we would stall for the full
|
|
309
|
+
# timeout on a path that is already recovering from an error.
|
|
310
|
+
return if transactional_error?(error)
|
|
311
|
+
|
|
312
|
+
# An already delivered handle (the common case - any sync dispatch, or a transaction that did
|
|
313
|
+
# some work before aborting) short-circuits inside `#wait` itself, so there is nothing to
|
|
314
|
+
# guard against here: it returns immediately without blocking.
|
|
315
|
+
wait(handle, max_wait_timeout: timeout, raise_response_error: false)
|
|
316
|
+
rescue ::Rdkafka::AbstractHandle::WaitTimeoutError, ::Rdkafka::RdkafkaError
|
|
317
|
+
# These two mean the same thing for us: we could not confirm the registration, either because
|
|
318
|
+
# the delivery did not arrive in time or because it failed outright. Aborting is still the
|
|
319
|
+
# right move, we just lose the mitigation for this one transaction.
|
|
320
|
+
#
|
|
321
|
+
# Deliberately narrow. A broader rescue would also swallow bugs of our own making (a
|
|
322
|
+
# `NoMethodError` on a handle we mis-tracked, say) and turn them into a silently skipped
|
|
323
|
+
# mitigation that nobody would ever notice.
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# @param error [Exception] error that caused the abort
|
|
328
|
+
# @return [Boolean] did this error come from librdkafka, meaning the transaction is already in
|
|
329
|
+
# an error state and will not deliver anything anymore
|
|
330
|
+
def transactional_error?(error)
|
|
331
|
+
return true if error.is_a?(::Rdkafka::RdkafkaError)
|
|
332
|
+
|
|
333
|
+
error.cause.is_a?(::Rdkafka::RdkafkaError)
|
|
334
|
+
end
|
|
335
|
+
|
|
266
336
|
# Reloads the underlying client instance if needed and allowed
|
|
267
337
|
#
|
|
268
338
|
# This should be used only in transactions as only then we can get fatal transactional
|
data/lib/waterdrop/producer.rb
CHANGED
|
@@ -67,6 +67,7 @@ module WaterDrop
|
|
|
67
67
|
@poller = nil
|
|
68
68
|
@idempotent_fatal_error_attempts = 0
|
|
69
69
|
@transaction_fatal_error_attempts = 0
|
|
70
|
+
@transaction_first_handle = nil
|
|
70
71
|
|
|
71
72
|
@status = Status.new
|
|
72
73
|
@messages = []
|
|
@@ -647,6 +648,12 @@ module WaterDrop
|
|
|
647
648
|
client.produce(**message)
|
|
648
649
|
end
|
|
649
650
|
|
|
651
|
+
# Remember the first delivery handle of the current transaction. Aborting while the very first
|
|
652
|
+
# produce is still in flight is what triggers librdkafka#4849, so the abort path waits on this
|
|
653
|
+
# handle to confirm the transaction is registered at the coordinator. See
|
|
654
|
+
# `#transactional_await_first_delivery`.
|
|
655
|
+
@transaction_first_handle ||= result if transactional? && @transaction_mutex.owned?
|
|
656
|
+
|
|
650
657
|
# Reset attempts counter on successful produce
|
|
651
658
|
@idempotent_fatal_error_attempts = 0
|
|
652
659
|
|
data/lib/waterdrop/version.rb
CHANGED
data/package-lock.json
CHANGED
|
@@ -286,9 +286,9 @@
|
|
|
286
286
|
}
|
|
287
287
|
},
|
|
288
288
|
"node_modules/smol-toml": {
|
|
289
|
-
"version": "1.
|
|
290
|
-
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.
|
|
291
|
-
"integrity": "sha512-
|
|
289
|
+
"version": "1.7.0",
|
|
290
|
+
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
|
|
291
|
+
"integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
|
|
292
292
|
"dev": true,
|
|
293
293
|
"license": "BSD-3-Clause",
|
|
294
294
|
"engines": {
|