Skip to content

Commit 04df8e1

Browse files
committed
feat: add etag conditional write support to dynamic config
1 parent 177cada commit 04df8e1

23 files changed

Lines changed: 1209 additions & 77 deletions

File tree

docs/api-reference/automatic-compaction-api.md

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ Instead of the automatic compaction API, you can use the supervisor API to submi
3737

3838
In this topic, `http://ROUTER_IP:ROUTER_PORT` is a placeholder for your Router service address and port. Replace it with the information for your deployment. For example, use `http://localhost:8888` for quickstart deployments.
3939

40+
## Concurrency control with ETag and If-Match
41+
42+
The automatic compaction write endpoints support optimistic concurrency control using HTTP `ETag` and `If-Match`. `GET /druid/coordinator/v1/config/compaction` returns an `ETag` header whose value is a stable hash of the underlying compaction configuration document (the union of cluster-level config and all per-datasource configs).
43+
44+
To guard a write against concurrent updates, pass the most recently observed ETag back in an `If-Match` header on `POST` or `DELETE`. The change only commits if the stored configuration still hashes to that ETag; otherwise the request fails with `412 Precondition Failed`. Re-`GET` to obtain the new value and ETag, re-apply your change, and retry. The `If-Match` header is optional; requests without it preserve the previous last-writer-wins behavior. `If-Match: *` matches any existing configuration.
45+
46+
Endpoints with this behavior: `POST /druid/coordinator/v1/config/compaction`, `DELETE /druid/coordinator/v1/config/compaction/{dataSource}`, `POST /druid/coordinator/v1/config/compaction/taskslots`, and the unified `POST /druid/indexer/v1/compaction/config/cluster`. See the equivalent section in the [Dynamic configuration API](./dynamic-configuration-api.md#concurrency-control-with-etag-and-if-match) for the general protocol and a client-flow example.
47+
4048
## Manage automatic compaction
4149

4250
### Create or update automatic compaction configuration
@@ -51,6 +59,12 @@ Note that this endpoint returns an HTTP `200 OK` message code even if the dataso
5159

5260
`POST` `/druid/coordinator/v1/config/compaction`
5361

62+
#### Header parameters
63+
64+
* `If-Match`
65+
* Type: String
66+
* Optional. Quoted ETag previously returned by `GET /druid/coordinator/v1/config/compaction`. When supplied, the update only commits if the stored compaction configuration still matches this ETag. Pass `*` to require only that some value is already stored. See [Concurrency control with ETag and If-Match](#concurrency-control-with-etag-and-if-match).
67+
5468
#### Responses
5569

5670
<Tabs>
@@ -60,6 +74,12 @@ Note that this endpoint returns an HTTP `200 OK` message code even if the dataso
6074

6175
*Successfully submitted auto compaction configuration*
6276

77+
</TabItem>
78+
<TabItem value="1b" label="412 PRECONDITION FAILED">
79+
80+
81+
*The `If-Match` header did not match the currently stored configuration, or another writer committed a change between this request's precondition check and write. Re-read the configuration and retry.*
82+
6383
</TabItem>
6484
</Tabs>
6585

@@ -140,6 +160,12 @@ Removes the automatic compaction configuration for a datasource. This updates th
140160

141161
`DELETE` `/druid/coordinator/v1/config/compaction/{dataSource}`
142162

163+
#### Header parameters
164+
165+
* `If-Match`
166+
* Type: String
167+
* Optional. Quoted ETag previously returned by `GET /druid/coordinator/v1/config/compaction`. When supplied, the delete only commits if the stored compaction configuration still matches this ETag. See [Concurrency control with ETag and If-Match](#concurrency-control-with-etag-and-if-match).
168+
143169
#### Responses
144170

145171
<Tabs>
@@ -155,6 +181,12 @@ Removes the automatic compaction configuration for a datasource. This updates th
155181

156182
*Datasource does not have automatic compaction or invalid datasource name*
157183

184+
</TabItem>
185+
<TabItem value="5b" label="412 PRECONDITION FAILED">
186+
187+
188+
*The `If-Match` header did not match the currently stored configuration, or another writer committed a change between this request's precondition check and write. Re-read the configuration and retry.*
189+
158190
</TabItem>
159191
</Tabs>
160192

@@ -215,6 +247,12 @@ To limit the maximum number of compaction tasks, use the optional query paramete
215247
* Default: 2147483647
216248
* Limits the maximum number of task slots for compaction tasks.
217249

250+
#### Header parameters
251+
252+
* `If-Match`
253+
* Type: String
254+
* Optional. Quoted ETag previously returned by `GET /druid/coordinator/v1/config/compaction`. When supplied, the update only commits if the stored compaction configuration still matches this ETag. See [Concurrency control with ETag and If-Match](#concurrency-control-with-etag-and-if-match).
255+
218256
#### Responses
219257

220258
<Tabs>
@@ -230,6 +268,12 @@ To limit the maximum number of compaction tasks, use the optional query paramete
230268

231269
*Invalid `max` value*
232270

271+
</TabItem>
272+
<TabItem value="9b" label="412 PRECONDITION FAILED">
273+
274+
275+
*The `If-Match` header did not match the currently stored configuration, or another writer committed a change between this request's precondition check and write. Re-read the configuration and retry.*
276+
233277
</TabItem>
234278
</Tabs>
235279

@@ -270,6 +314,8 @@ Retrieves all automatic compaction configurations. Returns a `compactionConfigs`
270314

271315
You can use this endpoint to retrieve `compactionTaskSlotRatio` and `maxCompactionTaskSlots` values for managing resource allocation of compaction tasks.
272316

317+
The response includes an `ETag` header that you can pass back in `If-Match` on a subsequent write to detect concurrent updates; see [Concurrency control with ETag and If-Match](#concurrency-control-with-etag-and-if-match).
318+
273319
#### URL
274320

275321
`GET` `/druid/coordinator/v1/config/compaction`
@@ -281,7 +327,7 @@ You can use this endpoint to retrieve `compactionTaskSlotRatio` and `maxCompacti
281327
<TabItem value="12" label="200 SUCCESS">
282328

283329

284-
*Successfully retrieved automatic compaction configurations*
330+
*Successfully retrieved automatic compaction configurations. The `ETag` response header carries an opaque identifier for the returned configuration version.*
285331

286332
</TabItem>
287333
</Tabs>
@@ -926,6 +972,12 @@ This policy specifies the datasources and intervals eligible for compaction and
926972

927973
`POST` `/druid/indexer/v1/compaction/config/cluster`
928974

975+
#### Header parameters
976+
977+
* `If-Match`
978+
* Type: String
979+
* Optional. Quoted ETag previously returned by `GET /druid/indexer/v1/compaction/config/cluster` (or `GET /druid/coordinator/v1/config/compaction`, since both reflect the same underlying configuration). When supplied, the update only commits if the stored configuration still matches this ETag. See [Concurrency control with ETag and If-Match](#concurrency-control-with-etag-and-if-match).
980+
929981
#### Responses
930982

931983
<Tabs>
@@ -941,6 +993,12 @@ This policy specifies the datasources and intervals eligible for compaction and
941993

942994
*Invalid `max` value*
943995

996+
</TabItem>
997+
<TabItem value="9b" label="412 PRECONDITION FAILED">
998+
999+
1000+
*The `If-Match` header did not match the currently stored configuration, or another writer committed a change between this request's precondition check and write. Re-read the configuration and retry.*
1001+
9441002
</TabItem>
9451003
</Tabs>
9461004

@@ -1002,6 +1060,8 @@ A successful request returns an HTTP `200 OK` message code and an empty response
10021060
Retrieves cluster-level configuration for compaction tasks which applies to all datasources, unless explicitly overridden in the datasource compaction config.
10031061
This includes all the fields listed in [Update cluster-level compaction config](#update-cluster-level-compaction-config).
10041062

1063+
The response includes an `ETag` header that you can pass back in `If-Match` on a subsequent write; see [Concurrency control with ETag and If-Match](#concurrency-control-with-etag-and-if-match).
1064+
10051065
#### URL
10061066

10071067
`GET` `/druid/indexer/v1/compaction/config/cluster`
@@ -1012,7 +1072,7 @@ This includes all the fields listed in [Update cluster-level compaction config](
10121072

10131073
<TabItem value="8" label="200 SUCCESS">
10141074

1015-
*Successfully retrieved cluster compaction configuration*
1075+
*Successfully retrieved cluster compaction configuration. The `ETag` response header carries an opaque identifier for the returned configuration version.*
10161076

10171077
</TabItem>
10181078
</Tabs>

docs/api-reference/dynamic-configuration-api.md

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,40 @@ In this topic, `http://ROUTER_IP:ROUTER_PORT` is a placeholder for your Router s
3333
Replace it with the information for your deployment.
3434
For example, use `http://localhost:8888` for quickstart deployments.
3535

36+
## Concurrency control with ETag and If-Match
37+
38+
Without coordination, two concurrent updates to the same dynamic configuration silently overwrite each other—the last writer wins and the earlier writer has no signal that their change was lost. To prevent this, the dynamic configuration endpoints support an optimistic-concurrency-control protocol modeled on HTTP `ETag` and `If-Match`:
39+
40+
* Every `GET` response for a dynamic configuration includes an `ETag` header. The value is a stable hash of the stored configuration bytes—identical bytes always produce the same ETag.
41+
* On a `POST` (or `DELETE`), supply the ETag you last observed in an `If-Match` header. The server only commits the change if the currently stored configuration still hashes to that ETag.
42+
* If another writer changed the configuration after your read, your request fails with `412 Precondition Failed`. Re-`GET` to obtain the new value and ETag, re-apply your change, and retry.
43+
* The `If-Match` header is **optional**. Requests without it preserve the previous last-writer-wins behavior.
44+
* `If-Match: *` is supported and matches any existing configuration (it only fails if no value has been stored yet).
45+
46+
Typical client flow:
47+
48+
```shell
49+
# Read current config and capture its ETag
50+
ETAG=$(curl -sD - "http://ROUTER_IP:ROUTER_PORT/druid/coordinator/v1/config" | awk '/^ETag:/ {print $2}' | tr -d '\r')
51+
52+
# Submit an update guarded by the ETag
53+
curl -X POST "http://ROUTER_IP:ROUTER_PORT/druid/coordinator/v1/config" \
54+
-H "Content-Type: application/json" \
55+
-H "If-Match: $ETAG" \
56+
-d @new-config.json
57+
# 200 on success, 412 if someone else updated the config in between.
58+
```
59+
60+
Endpoints that support this protocol are noted individually below.
61+
3662
## Coordinator dynamic configuration
3763

3864
The Coordinator has dynamic configurations to tune certain behavior on the fly, without requiring a service restart.
3965
For information on the supported properties, see [Coordinator dynamic configuration](../configuration/index.md#dynamic-configuration).
4066

4167
### Get dynamic configuration
4268

43-
Retrieves the current Coordinator dynamic configuration. Returns a JSON object with the dynamic configuration properties.
69+
Retrieves the current Coordinator dynamic configuration. Returns a JSON object with the dynamic configuration properties. The response includes an `ETag` header that can be used with `If-Match` on a subsequent update; see [Concurrency control with ETag and If-Match](#concurrency-control-with-etag-and-if-match).
4470

4571
#### URL
4672

@@ -53,7 +79,7 @@ Retrieves the current Coordinator dynamic configuration. Returns a JSON object w
5379
<TabItem value="1" label="200 SUCCESS">
5480

5581

56-
*Successfully retrieved dynamic configuration*
82+
*Successfully retrieved dynamic configuration. The `ETag` response header carries an opaque identifier for the returned configuration version.*
5783

5884
</TabItem>
5985
</Tabs>
@@ -136,6 +162,9 @@ The endpoint supports a set of optional header parameters to populate the `autho
136162
* `X-Druid-Comment`
137163
* Type: String
138164
* Description for the update.
165+
* `If-Match`
166+
* Type: String
167+
* Optional. Quoted ETag previously returned by `GET /druid/coordinator/v1/config`. When supplied, the update only commits if the currently stored configuration still matches this ETag. Pass `*` to require only that some value is already stored. See [Concurrency control with ETag and If-Match](#concurrency-control-with-etag-and-if-match).
139168

140169
#### Responses
141170

@@ -146,6 +175,12 @@ The endpoint supports a set of optional header parameters to populate the `autho
146175

147176
*Successfully updated dynamic configuration*
148177

178+
</TabItem>
179+
<TabItem value="4b" label="412 PRECONDITION FAILED">
180+
181+
182+
*The `If-Match` header did not match the currently stored configuration, or another writer committed a change between this request's precondition check and write. Re-read the configuration and retry.*
183+
149184
</TabItem>
150185
</Tabs>
151186

@@ -319,7 +354,7 @@ These settings control broker behavior such as query blocking rules and default
319354
320355
### Get broker dynamic configuration
321356

322-
Retrieves the current Broker dynamic configuration. Returns a JSON object with the dynamic configuration properties.
357+
Retrieves the current Broker dynamic configuration. Returns a JSON object with the dynamic configuration properties. The response includes an `ETag` header that can be used with `If-Match` on a subsequent update; see [Concurrency control with ETag and If-Match](#concurrency-control-with-etag-and-if-match).
323358

324359
#### URL
325360

@@ -332,7 +367,7 @@ Retrieves the current Broker dynamic configuration. Returns a JSON object with t
332367
<TabItem value="25" label="200 SUCCESS">
333368

334369

335-
*Successfully retrieved broker dynamic configuration*
370+
*Successfully retrieved broker dynamic configuration. The `ETag` response header carries an opaque identifier for the returned configuration version.*
336371

337372
</TabItem>
338373
</Tabs>
@@ -412,6 +447,10 @@ The endpoint supports a set of optional header parameters to populate the audit
412447
* Type: String
413448
* Comment describing the change.
414449

450+
* `If-Match`
451+
* Type: String
452+
* Optional. Quoted ETag previously returned by `GET /druid/coordinator/v1/broker/config`. When supplied, the update only commits if the currently stored configuration still matches this ETag. Pass `*` to require only that some value is already stored. See [Concurrency control with ETag and If-Match](#concurrency-control-with-etag-and-if-match).
453+
415454
#### Responses
416455

417456
<Tabs>
@@ -421,6 +460,12 @@ The endpoint supports a set of optional header parameters to populate the audit
421460

422461
*Successfully updated configuration*
423462

463+
</TabItem>
464+
<TabItem value="28b" label="412 PRECONDITION FAILED">
465+
466+
467+
*The `If-Match` header did not match the currently stored configuration, or another writer committed a change between this request's precondition check and write. Re-read the configuration and retry.*
468+
424469
</TabItem>
425470
</Tabs>
426471

@@ -719,6 +764,7 @@ For information on the supported properties, see [Overlord dynamic configuration
719764
Retrieves the current Overlord dynamic configuration.
720765
Returns a JSON object with the dynamic configuration properties.
721766
Returns an empty response body if there is no current Overlord dynamic configuration.
767+
When a configuration is present, the response includes an `ETag` header that can be used with `If-Match` on a subsequent update; see [Concurrency control with ETag and If-Match](#concurrency-control-with-etag-and-if-match).
722768

723769
#### URL
724770

@@ -731,7 +777,7 @@ Returns an empty response body if there is no current Overlord dynamic configura
731777
<TabItem value="10" label="200 SUCCESS">
732778

733779

734-
*Successfully retrieved dynamic configuration*
780+
*Successfully retrieved dynamic configuration. When a configuration is stored, the `ETag` response header carries an opaque identifier for the returned configuration version.*
735781

736782
</TabItem>
737783
</Tabs>
@@ -799,6 +845,9 @@ The endpoint supports a set of optional header parameters to populate the `autho
799845
* `X-Druid-Comment`
800846
* Type: String
801847
* Description for the update.
848+
* `If-Match`
849+
* Type: String
850+
* Optional. Quoted ETag previously returned by `GET /druid/indexer/v1/worker`. When supplied, the update only commits if the currently stored configuration still matches this ETag. Pass `*` to require only that some value is already stored. See [Concurrency control with ETag and If-Match](#concurrency-control-with-etag-and-if-match).
802851

803852
#### Responses
804853

@@ -809,6 +858,12 @@ The endpoint supports a set of optional header parameters to populate the `autho
809858

810859
*Successfully updated dynamic configuration*
811860

861+
</TabItem>
862+
<TabItem value="13b" label="412 PRECONDITION FAILED">
863+
864+
865+
*The `If-Match` header did not match the currently stored configuration, or another writer committed a change between this request's precondition check and write. Re-read the configuration and retry.*
866+
812867
</TabItem>
813868
</Tabs>
814869

extensions-core/kubernetes-overlord-extensions/src/main/java/org/apache/druid/k8s/overlord/execution/KubernetesTaskExecutionConfigResource.java

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@
2525
import org.apache.druid.audit.AuditManager;
2626
import org.apache.druid.common.config.ConfigManager;
2727
import org.apache.druid.common.config.JacksonConfigManager;
28+
import org.apache.druid.error.DruidException;
2829
import org.apache.druid.java.util.common.Intervals;
2930
import org.apache.druid.java.util.common.logger.Logger;
31+
import org.apache.druid.server.http.DynamicConfigEtagHelper;
32+
import org.apache.druid.server.http.ServletResourceUtils;
3033
import org.apache.druid.server.http.security.ConfigResourceFilter;
3134
import org.apache.druid.server.security.AuthorizationUtils;
3235
import org.joda.time.Interval;
@@ -84,23 +87,27 @@ public Response setExecutionConfig(
8487
@Context final HttpServletRequest req
8588
)
8689
{
87-
KubernetesTaskRunnerDynamicConfig currentConfig = getDynamicConfig();
88-
KubernetesTaskRunnerDynamicConfig mergedConfig = dynamicConfig;
90+
try {
91+
final KubernetesTaskRunnerDynamicConfig currentConfig = getDynamicConfig();
92+
final KubernetesTaskRunnerDynamicConfig mergedConfig = currentConfig == null
93+
? dynamicConfig
94+
: currentConfig.merge(dynamicConfig);
95+
final ConfigManager.SetResult setResult = configManager.setIfMatch(
96+
KubernetesTaskRunnerDynamicConfig.CONFIG_KEY,
97+
DynamicConfigEtagHelper.getIfMatch(req),
98+
mergedConfig,
99+
AuthorizationUtils.buildAuditInfo(req)
100+
);
101+
if (setResult.isOk()) {
102+
log.info("Updating K8s execution configs: %s", mergedConfig);
89103

90-
if (currentConfig != null) {
91-
mergedConfig = currentConfig.merge(dynamicConfig);
104+
return Response.ok().build();
105+
} else {
106+
return DynamicConfigEtagHelper.toErrorResponse(setResult);
107+
}
92108
}
93-
final ConfigManager.SetResult setResult = configManager.set(
94-
KubernetesTaskRunnerDynamicConfig.CONFIG_KEY,
95-
mergedConfig,
96-
AuthorizationUtils.buildAuditInfo(req)
97-
);
98-
if (setResult.isOk()) {
99-
log.info("Updating K8s execution configs: %s", mergedConfig);
100-
101-
return Response.ok().build();
102-
} else {
103-
return Response.status(Response.Status.BAD_REQUEST).build();
109+
catch (DruidException e) {
110+
return ServletResourceUtils.buildErrorResponseFrom(e);
104111
}
105112
}
106113

@@ -154,7 +161,14 @@ public Response getExecutionConfigHistory(
154161
@ResourceFilters(ConfigResourceFilter.class)
155162
public Response getExecutionConfig()
156163
{
157-
return Response.ok(getDynamicConfig()).build();
164+
return DynamicConfigEtagHelper.buildReadResponseWithEtag(
165+
() -> configManager.getCurrentBytes(KubernetesTaskRunnerDynamicConfig.CONFIG_KEY),
166+
currentBytes -> configManager.convertByteToConfig(
167+
currentBytes,
168+
KubernetesTaskRunnerDynamicConfig.class,
169+
null
170+
)
171+
);
158172
}
159173

160174
private KubernetesTaskRunnerDynamicConfig getDynamicConfig()

0 commit comments

Comments
 (0)