couchbase 3.8.1 → 3.8.2

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.
@@ -42,6 +42,7 @@
42
42
  #include "internal/utils.hxx"
43
43
  #include "staged_mutation.hxx"
44
44
 
45
+ #include <cassert>
45
46
  #include <optional>
46
47
 
47
48
  namespace couchbase::core::transactions
@@ -233,10 +234,12 @@ attempt_context_impl::get(const core::document_id& id) -> transaction_get_result
233
234
  void
234
235
  attempt_context_impl::get(const core::document_id& id, Callback&& cb)
235
236
  {
236
- if (op_list_.get_mode().is_query()) {
237
- return get_with_query(id, false, std::move(cb));
238
- }
239
237
  cache_error_async(cb, [self = shared_from_this(), id, cb]() mutable {
238
+ // Mode is read after cache_error_async incremented the op count, closing the
239
+ // race with set_query_mode() -- see the comment in insert() for the rationale.
240
+ if (self->op_list_.get_mode().is_query()) {
241
+ return self->get_with_query(id, false, std::move(cb));
242
+ }
240
243
  self->check_if_done(cb);
241
244
  self->do_get(
242
245
  id,
@@ -323,10 +326,12 @@ void
323
326
  attempt_context_impl::get_optional(const core::document_id& id, Callback&& cb)
324
327
  {
325
328
 
326
- if (op_list_.get_mode().is_query()) {
327
- return get_with_query(id, true, std::move(cb));
328
- }
329
329
  cache_error_async(cb, [self = shared_from_this(), id, cb]() mutable {
330
+ // Mode is read after cache_error_async incremented the op count, closing the
331
+ // race with set_query_mode() -- see the comment in insert() for the rationale.
332
+ if (self->op_list_.get_mode().is_query()) {
333
+ return self->get_with_query(id, true, std::move(cb));
334
+ }
330
335
  self->ensure_open_bucket(
331
336
  id.bucket(), [self, id, cb = std::move(cb)](std::error_code ec) mutable {
332
337
  if (ec) {
@@ -886,11 +891,13 @@ attempt_context_impl::replace(const transaction_get_result& document,
886
891
  Callback&& cb)
887
892
  {
888
893
 
889
- if (op_list_.get_mode().is_query()) {
890
- return replace_raw_with_query(document, std::move(content), std::move(cb));
891
- }
892
894
  return cache_error_async(
893
895
  cb, [self = shared_from_this(), cb, document, content = std::move(content)]() mutable {
896
+ // Mode is read after cache_error_async incremented the op count, closing the
897
+ // race with set_query_mode() -- see the comment in insert() for the rationale.
898
+ if (self->op_list_.get_mode().is_query()) {
899
+ return self->replace_raw_with_query(document, std::move(content), std::move(cb));
900
+ }
894
901
  self->ensure_open_bucket(
895
902
  document.bucket(),
896
903
  [self, cb = std::move(cb), document, content = std::move(content)](
@@ -1026,6 +1033,13 @@ attempt_context_impl::create_staged_replace(
1026
1033
  const std::optional<document_metadata>& document_metadata,
1027
1034
  Handler&& cb)
1028
1035
  {
1036
+ // ExtThreadSafety invariant -- see create_staged_insert() for the full rationale and
1037
+ // the spec reference (couchbase-transactions-specs/transactions-extensions.md,
1038
+ // "ExtThreadSafety"). replace() reads the mode after this op is counted, so reaching
1039
+ // this staging point in query mode means that ordering has regressed.
1040
+ assert(!op_list_.query_mode_entered() &&
1041
+ "ExtThreadSafety: KV replace staged after query mode entered -- would be missing "
1042
+ "from BEGIN WORK and lost at commit");
1029
1043
  operations::mutate_in_request req{ id };
1030
1044
  const bool binary =
1031
1045
  codec::codec_flags::has_common_flags(content.flags, codec::codec_flags::binary_common_flags);
@@ -1274,11 +1288,20 @@ attempt_context_impl::insert(const core::document_id& id,
1274
1288
  codec::encoded_value content,
1275
1289
  Callback&& cb)
1276
1290
  {
1277
- if (op_list_.get_mode().is_query()) {
1278
- return insert_raw_with_query(id, std::move(content), std::move(cb));
1279
- }
1280
1291
  return cache_error_async(
1281
1292
  cb, [self = shared_from_this(), id, cb, content = std::move(content)]() mutable {
1293
+ // The op count was incremented by cache_error_async before this lambda runs.
1294
+ // Reading the mode here -- after the increment -- closes a race with
1295
+ // set_query_mode(): a query switching to query mode first waits for all
1296
+ // in-flight KV ops to drain before issuing BEGIN WORK, so once this op is
1297
+ // counted, either we observe KV mode and our staged mutation is guaranteed to
1298
+ // be part of BEGIN WORK, or we observe query mode and route through query.
1299
+ // Checking the mode before the increment (as the code used to) left a window
1300
+ // where a query could flip the mode in the gap, after which this op would
1301
+ // stage a KV mutation that BEGIN WORK had already missed -- silently losing it.
1302
+ if (self->op_list_.get_mode().is_query()) {
1303
+ return self->insert_raw_with_query(id, std::move(content), std::move(cb));
1304
+ }
1282
1305
  self->ensure_open_bucket(
1283
1306
  id.bucket(),
1284
1307
  [self, id, content = std::move(content), cb = std::move(cb)](std::error_code ec) mutable {
@@ -1452,10 +1475,12 @@ void
1452
1475
  attempt_context_impl::remove(const transaction_get_result& document, VoidCallback&& cb)
1453
1476
  {
1454
1477
 
1455
- if (op_list_.get_mode().is_query()) {
1456
- return remove_with_query(document, std::move(cb));
1457
- }
1458
1478
  return cache_error_async(cb, [self = shared_from_this(), document, cb]() mutable {
1479
+ // Mode is read after cache_error_async incremented the op count, closing the
1480
+ // race with set_query_mode() -- see the comment in insert() for the rationale.
1481
+ if (self->op_list_.get_mode().is_query()) {
1482
+ return self->remove_with_query(document, std::move(cb));
1483
+ }
1459
1484
  self->check_if_done(cb);
1460
1485
  self->ensure_open_bucket(
1461
1486
  document.bucket(), [self, document, cb = std::move(cb)](std::error_code ec) mutable {
@@ -1539,6 +1564,15 @@ attempt_context_impl::remove(const transaction_get_result& document, VoidCallbac
1539
1564
  return error_handler(
1540
1565
  *ec, "before_staged_remove hook raised error", std::move(cb));
1541
1566
  }
1567
+ // ExtThreadSafety invariant -- see create_staged_insert() for the
1568
+ // full rationale and spec reference
1569
+ // (couchbase-transactions-specs/transactions-extensions.md,
1570
+ // "ExtThreadSafety"). remove() reads the mode after this op is
1571
+ // counted, so reaching this staging point in query mode means that
1572
+ // ordering has regressed.
1573
+ assert(!self->op_list_.query_mode_entered() &&
1574
+ "ExtThreadSafety: KV remove staged after query mode entered -- "
1575
+ "would be missing from BEGIN WORK and lost at commit");
1542
1576
  CB_ATTEMPT_CTX_LOG_TRACE(self,
1543
1577
  "about to remove doc {} with cas {}",
1544
1578
  document.id(),
@@ -2203,59 +2237,69 @@ make_kv_txdata(std::optional<transaction_get_result> doc = std::nullopt) -> tao:
2203
2237
  void
2204
2238
  attempt_context_impl::get_with_query(const core::document_id& id, bool optional, Callback&& cb)
2205
2239
  {
2206
- cache_error_async(cb, [self = shared_from_this(), id, optional, cb]() mutable {
2207
- couchbase::transactions::transaction_query_options opts;
2208
- opts.readonly(true);
2209
- return self->wrap_query(
2210
- KV_GET,
2211
- opts,
2212
- make_params(id, {}),
2213
- make_kv_txdata(),
2214
- STAGE_QUERY_KV_GET,
2215
- true,
2216
- {},
2217
- [self, id, optional, cb = std::move(cb)](std::exception_ptr err,
2218
- core::operations::query_response resp) mutable {
2219
- if (resp.ctx.ec == couchbase::errc::key_value::document_not_found) {
2240
+ // Called only from get()/get_optional(), already within their cache_error_async()
2241
+ // (op counted, existing_error() checked); see insert_raw_with_query() for why we do
2242
+ // not re-wrap.
2243
+ auto self = shared_from_this();
2244
+ couchbase::transactions::transaction_query_options opts;
2245
+ opts.readonly(true);
2246
+ return self->wrap_query(
2247
+ KV_GET,
2248
+ opts,
2249
+ make_params(id, {}),
2250
+ make_kv_txdata(),
2251
+ STAGE_QUERY_KV_GET,
2252
+ true,
2253
+ {},
2254
+ [self, id, optional, cb = std::move(cb)](std::exception_ptr err,
2255
+ core::operations::query_response resp) mutable {
2256
+ if (resp.ctx.ec == couchbase::errc::key_value::document_not_found) {
2257
+ // A non-optional get() must fail rather than report an empty result: its callback contract
2258
+ // (and the synchronous get() wrapper) dereferences the returned optional, so err==nullptr
2259
+ // with res==nullopt would be a null dereference. Mirror the rows-empty handling below and
2260
+ // the KV get() path.
2261
+ if (optional) {
2220
2262
  return self->op_completed_with_callback(std::move(cb),
2221
2263
  std::optional<transaction_get_result>());
2222
2264
  }
2223
- if (!err) {
2224
- // make a transaction_get_result from the row...
2225
- try {
2226
- if (resp.rows.empty()) {
2227
- if (optional) {
2228
- return self->op_completed_with_callback(std::move(cb),
2229
- std::optional<transaction_get_result>());
2230
- }
2231
- return self->op_completed_with_error(
2232
- std::move(cb),
2233
- transaction_operation_failed(FAIL_DOC_NOT_FOUND, "document not found"));
2265
+ return self->op_completed_with_error(
2266
+ std::move(cb), transaction_operation_failed(FAIL_DOC_NOT_FOUND, "document not found"));
2267
+ }
2268
+ if (!err) {
2269
+ // make a transaction_get_result from the row...
2270
+ try {
2271
+ if (resp.rows.empty()) {
2272
+ if (optional) {
2273
+ return self->op_completed_with_callback(std::move(cb),
2274
+ std::optional<transaction_get_result>());
2234
2275
  }
2235
- CB_ATTEMPT_CTX_LOG_TRACE(self, "get_with_query got: {}", resp.rows.front());
2236
- transaction_get_result doc(id, core::utils::json::parse(resp.rows.front()));
2237
- return self->op_completed_with_callback(std::move(cb),
2238
- std::optional<transaction_get_result>(doc));
2239
- } catch (const std::exception& e) {
2240
- // TODO(SA): unsure what to do here, but this is pretty fatal, so
2241
2276
  return self->op_completed_with_error(
2242
- std::move(cb), transaction_operation_failed(FAIL_OTHER, e.what()));
2277
+ std::move(cb),
2278
+ transaction_operation_failed(FAIL_DOC_NOT_FOUND, "document not found"));
2243
2279
  }
2280
+ CB_ATTEMPT_CTX_LOG_TRACE(self, "get_with_query got: {}", resp.rows.front());
2281
+ transaction_get_result doc(id, core::utils::json::parse(resp.rows.front()));
2282
+ return self->op_completed_with_callback(std::move(cb),
2283
+ std::optional<transaction_get_result>(doc));
2284
+ } catch (const std::exception& e) {
2285
+ // Unexpected error here: fail with FAIL_OTHER so the transaction rolls back.
2286
+ return self->op_completed_with_error(std::move(cb),
2287
+ transaction_operation_failed(FAIL_OTHER, e.what()));
2244
2288
  }
2245
- // for get_optional. <sigh>
2246
- if (optional) {
2247
- try {
2248
- std::rethrow_exception(err);
2249
- } catch (const document_not_found&) {
2250
- return self->op_completed_with_callback(std::move(cb),
2251
- std::optional<transaction_get_result>());
2252
- } catch (...) {
2253
- return self->op_completed_with_error(std::move(cb), std::current_exception());
2254
- }
2289
+ }
2290
+ // for get_optional. <sigh>
2291
+ if (optional) {
2292
+ try {
2293
+ std::rethrow_exception(err);
2294
+ } catch (const document_not_found&) {
2295
+ return self->op_completed_with_callback(std::move(cb),
2296
+ std::optional<transaction_get_result>());
2297
+ } catch (...) {
2298
+ return self->op_completed_with_error(std::move(cb), std::current_exception());
2255
2299
  }
2256
- return self->op_completed_with_error(std::move(cb), std::move(err));
2257
- });
2258
- });
2300
+ }
2301
+ return self->op_completed_with_error(std::move(cb), std::move(err));
2302
+ });
2259
2303
  }
2260
2304
 
2261
2305
  void
@@ -2263,46 +2307,55 @@ attempt_context_impl::insert_raw_with_query(const core::document_id& id,
2263
2307
  codec::encoded_value content,
2264
2308
  Callback&& cb)
2265
2309
  {
2266
- cache_error_async(
2267
- cb, [self = shared_from_this(), id, content = std::move(content), cb]() mutable {
2268
- const couchbase::transactions::transaction_query_options opts;
2269
- return self->wrap_query(
2270
- KV_INSERT,
2271
- opts,
2272
- make_params(id, std::move(content)),
2273
- make_kv_txdata(),
2274
- STAGE_QUERY_KV_INSERT,
2275
- true,
2276
- {},
2277
- [self, id, cb = std::move(cb)](const std::exception_ptr& err,
2278
- core::operations::query_response resp) mutable {
2279
- if (err) {
2280
- try {
2281
- std::rethrow_exception(err);
2282
- } catch (const transaction_operation_failed& e) {
2283
- return self->op_completed_with_error(std::move(cb), e);
2284
- } catch (const op_exception& ex) {
2285
- return self->op_completed_with_error(std::move(cb), ex);
2286
- } catch (const std::exception& e) {
2287
- return self->op_completed_with_error(
2288
- std::move(cb), transaction_operation_failed(FAIL_OTHER, e.what()));
2289
- } catch (...) {
2290
- return self->op_completed_with_error(
2291
- std::move(cb), transaction_operation_failed(FAIL_OTHER, "unexpected error"));
2292
- }
2293
- }
2294
- // make a transaction_get_result from the row...
2295
- try {
2296
- CB_ATTEMPT_CTX_LOG_TRACE(self, "insert_raw_with_query got: {}", resp.rows.front());
2297
- transaction_get_result doc(id, core::utils::json::parse(resp.rows.front()));
2298
- return self->op_completed_with_callback(std::move(cb),
2299
- std::optional<transaction_get_result>(doc));
2300
- } catch (const std::exception& e) {
2301
- // TODO(SA): unsure what to do here, but this is pretty fatal, so
2302
- return self->op_completed_with_error(
2303
- std::move(cb), transaction_operation_failed(FAIL_OTHER, e.what()));
2304
- }
2305
- });
2310
+ // Called only from insert(), already within its cache_error_async(): the op has
2311
+ // been counted in the wait-group and existing_error() has been checked, so we must
2312
+ // not wrap again here -- double-counting would leave the op permanently in-flight
2313
+ // and deadlock the commit/rollback wait.
2314
+ auto self = shared_from_this();
2315
+ const couchbase::transactions::transaction_query_options opts;
2316
+ return self->wrap_query(
2317
+ KV_INSERT,
2318
+ opts,
2319
+ make_params(id, std::move(content)),
2320
+ make_kv_txdata(),
2321
+ STAGE_QUERY_KV_INSERT,
2322
+ true,
2323
+ {},
2324
+ [self, id, cb = std::move(cb)](const std::exception_ptr& err,
2325
+ core::operations::query_response resp) mutable {
2326
+ if (err) {
2327
+ try {
2328
+ std::rethrow_exception(err);
2329
+ } catch (const transaction_operation_failed& e) {
2330
+ return self->op_completed_with_error(std::move(cb), e);
2331
+ } catch (const op_exception& ex) {
2332
+ return self->op_completed_with_error(std::move(cb), ex);
2333
+ } catch (const std::exception& e) {
2334
+ return self->op_completed_with_error(std::move(cb),
2335
+ transaction_operation_failed(FAIL_OTHER, e.what()));
2336
+ } catch (...) {
2337
+ return self->op_completed_with_error(
2338
+ std::move(cb), transaction_operation_failed(FAIL_OTHER, "unexpected error"));
2339
+ }
2340
+ }
2341
+ // make a transaction_get_result from the row...
2342
+ try {
2343
+ if (resp.rows.empty()) {
2344
+ // A successful insert query must return the staged document row; no rows is an
2345
+ // unexpected protocol condition. front() on an empty vector is UB, so guard explicitly.
2346
+ return self->op_completed_with_error(
2347
+ std::move(cb),
2348
+ transaction_operation_failed(FAIL_OTHER, "insert query returned no rows"));
2349
+ }
2350
+ CB_ATTEMPT_CTX_LOG_TRACE(self, "insert_raw_with_query got: {}", resp.rows.front());
2351
+ transaction_get_result doc(id, core::utils::json::parse(resp.rows.front()));
2352
+ return self->op_completed_with_callback(std::move(cb),
2353
+ std::optional<transaction_get_result>(doc));
2354
+ } catch (const std::exception& e) {
2355
+ // Unexpected error here: fail with FAIL_OTHER so the transaction rolls back.
2356
+ return self->op_completed_with_error(std::move(cb),
2357
+ transaction_operation_failed(FAIL_OTHER, e.what()));
2358
+ }
2306
2359
  });
2307
2360
  }
2308
2361
 
@@ -2311,91 +2364,103 @@ attempt_context_impl::replace_raw_with_query(const transaction_get_result& docum
2311
2364
  codec::encoded_value content,
2312
2365
  Callback&& cb)
2313
2366
  {
2314
- cache_error_async(
2315
- cb, [self = shared_from_this(), document, content = std::move(content), cb]() mutable {
2316
- const couchbase::transactions::transaction_query_options opts;
2317
- return self->wrap_query(
2318
- KV_REPLACE,
2319
- opts,
2320
- make_params(document.id(), std::move(content)),
2321
- make_kv_txdata(document),
2322
- STAGE_QUERY_KV_REPLACE,
2323
- true,
2324
- {},
2325
- [self, id = document.id(), cb = std::move(cb)](
2326
- const std::exception_ptr& err, core::operations::query_response resp) mutable {
2327
- if (err) {
2328
- try {
2329
- std::rethrow_exception(err);
2330
- } catch (const query_cas_mismatch& e) {
2331
- return self->op_completed_with_error(
2332
- std::move(cb), transaction_operation_failed(FAIL_CAS_MISMATCH, e.what()).retry());
2333
- } catch (const document_not_found& e) {
2334
- return self->op_completed_with_error(
2335
- std::move(cb), transaction_operation_failed(FAIL_DOC_NOT_FOUND, e.what()).retry());
2336
- } catch (const transaction_operation_failed& e) {
2337
- return self->op_completed_with_error(std::move(cb), e);
2338
- } catch (std::exception& e) {
2339
- return self->op_completed_with_error(
2340
- std::move(cb), transaction_operation_failed(FAIL_OTHER, e.what()));
2341
- } catch (...) {
2342
- return self->op_completed_with_error(
2343
- std::move(cb), transaction_operation_failed(FAIL_OTHER, "unexpected exception"));
2344
- }
2345
- }
2346
- // make a transaction_get_result from the row...
2347
- try {
2348
- CB_ATTEMPT_CTX_LOG_TRACE(self, "replace_raw_with_query got: {}", resp.rows.front());
2349
- transaction_get_result doc(id, core::utils::json::parse(resp.rows.front()));
2350
- return self->op_completed_with_callback(std::move(cb),
2351
- std::optional<transaction_get_result>(doc));
2352
- } catch (const std::exception& e) {
2353
- // TODO(SA): unsure what to do here, but this is pretty fatal, so
2354
- return self->op_completed_with_error(
2355
- std::move(cb), transaction_operation_failed(FAIL_OTHER, e.what()));
2356
- }
2357
- });
2367
+ // Called only from replace(), already within its cache_error_async() (op counted,
2368
+ // existing_error() checked); see insert_raw_with_query() for why we do not re-wrap.
2369
+ auto self = shared_from_this();
2370
+ const couchbase::transactions::transaction_query_options opts;
2371
+ return self->wrap_query(
2372
+ KV_REPLACE,
2373
+ opts,
2374
+ make_params(document.id(), std::move(content)),
2375
+ make_kv_txdata(document),
2376
+ STAGE_QUERY_KV_REPLACE,
2377
+ true,
2378
+ {},
2379
+ [self, id = document.id(), cb = std::move(cb)](const std::exception_ptr& err,
2380
+ core::operations::query_response resp) mutable {
2381
+ if (err) {
2382
+ try {
2383
+ std::rethrow_exception(err);
2384
+ } catch (const query_cas_mismatch& e) {
2385
+ return self->op_completed_with_error(
2386
+ std::move(cb), transaction_operation_failed(FAIL_CAS_MISMATCH, e.what()).retry());
2387
+ } catch (const document_not_found& e) {
2388
+ return self->op_completed_with_error(
2389
+ std::move(cb), transaction_operation_failed(FAIL_DOC_NOT_FOUND, e.what()).retry());
2390
+ } catch (const transaction_operation_failed& e) {
2391
+ return self->op_completed_with_error(std::move(cb), e);
2392
+ } catch (const op_exception& ex) {
2393
+ return self->op_completed_with_error(std::move(cb), ex);
2394
+ } catch (std::exception& e) {
2395
+ return self->op_completed_with_error(std::move(cb),
2396
+ transaction_operation_failed(FAIL_OTHER, e.what()));
2397
+ } catch (...) {
2398
+ return self->op_completed_with_error(
2399
+ std::move(cb), transaction_operation_failed(FAIL_OTHER, "unexpected exception"));
2400
+ }
2401
+ }
2402
+ // make a transaction_get_result from the row...
2403
+ try {
2404
+ if (resp.rows.empty()) {
2405
+ // A successful replace query must return the staged document row; no rows is an
2406
+ // unexpected protocol condition. front() on an empty vector is UB, so guard explicitly.
2407
+ return self->op_completed_with_error(
2408
+ std::move(cb),
2409
+ transaction_operation_failed(FAIL_OTHER, "replace query returned no rows"));
2410
+ }
2411
+ CB_ATTEMPT_CTX_LOG_TRACE(self, "replace_raw_with_query got: {}", resp.rows.front());
2412
+ transaction_get_result doc(id, core::utils::json::parse(resp.rows.front()));
2413
+ return self->op_completed_with_callback(std::move(cb),
2414
+ std::optional<transaction_get_result>(doc));
2415
+ } catch (const std::exception& e) {
2416
+ // Unexpected error here: fail with FAIL_OTHER so the transaction rolls back.
2417
+ return self->op_completed_with_error(std::move(cb),
2418
+ transaction_operation_failed(FAIL_OTHER, e.what()));
2419
+ }
2358
2420
  });
2359
2421
  }
2360
2422
 
2361
2423
  void
2362
2424
  attempt_context_impl::remove_with_query(const transaction_get_result& document, VoidCallback&& cb)
2363
2425
  {
2364
- cache_error_async(cb, [self = shared_from_this(), document, cb]() {
2365
- const couchbase::transactions::transaction_query_options opts;
2366
- return self->wrap_query(
2367
- KV_REMOVE,
2368
- opts,
2369
- make_params(document.id(), {}),
2370
- make_kv_txdata(document),
2371
- STAGE_QUERY_KV_REMOVE,
2372
- true,
2373
- {},
2374
- [self, id = document.id(), cb](const std::exception_ptr& err,
2375
- const core::operations::query_response& /* resp */) mutable {
2376
- if (err) {
2377
- try {
2378
- std::rethrow_exception(err);
2379
- } catch (const transaction_operation_failed& e) {
2380
- return self->op_completed_with_error(std::move(cb), e);
2381
- } catch (const document_not_found& e) {
2382
- return self->op_completed_with_error(
2383
- std::move(cb), transaction_operation_failed(FAIL_DOC_NOT_FOUND, e.what()).retry());
2384
- } catch (const query_cas_mismatch& e) {
2385
- return self->op_completed_with_error(
2386
- std::move(cb), transaction_operation_failed(FAIL_CAS_MISMATCH, e.what()).retry());
2387
- } catch (const std::exception& e) {
2388
- return self->op_completed_with_error(
2389
- std::move(cb), transaction_operation_failed(FAIL_OTHER, e.what()));
2390
- } catch (...) {
2391
- return self->op_completed_with_error(
2392
- std::move(cb), transaction_operation_failed(FAIL_OTHER, "unexpected exception"));
2393
- }
2426
+ // Called only from remove(), already within its cache_error_async() (op counted,
2427
+ // existing_error() checked); see insert_raw_with_query() for why we do not re-wrap.
2428
+ auto self = shared_from_this();
2429
+ const couchbase::transactions::transaction_query_options opts;
2430
+ return self->wrap_query(
2431
+ KV_REMOVE,
2432
+ opts,
2433
+ make_params(document.id(), {}),
2434
+ make_kv_txdata(document),
2435
+ STAGE_QUERY_KV_REMOVE,
2436
+ true,
2437
+ {},
2438
+ [self, id = document.id(), cb = std::move(cb)](
2439
+ const std::exception_ptr& err, const core::operations::query_response& /* resp */) mutable {
2440
+ if (err) {
2441
+ try {
2442
+ std::rethrow_exception(err);
2443
+ } catch (const transaction_operation_failed& e) {
2444
+ return self->op_completed_with_error(std::move(cb), e);
2445
+ } catch (const document_not_found& e) {
2446
+ return self->op_completed_with_error(
2447
+ std::move(cb), transaction_operation_failed(FAIL_DOC_NOT_FOUND, e.what()).retry());
2448
+ } catch (const query_cas_mismatch& e) {
2449
+ return self->op_completed_with_error(
2450
+ std::move(cb), transaction_operation_failed(FAIL_CAS_MISMATCH, e.what()).retry());
2451
+ } catch (const op_exception& ex) {
2452
+ return self->op_completed_with_error(std::move(cb), ex);
2453
+ } catch (const std::exception& e) {
2454
+ return self->op_completed_with_error(std::move(cb),
2455
+ transaction_operation_failed(FAIL_OTHER, e.what()));
2456
+ } catch (...) {
2457
+ return self->op_completed_with_error(
2458
+ std::move(cb), transaction_operation_failed(FAIL_OTHER, "unexpected exception"));
2394
2459
  }
2395
- // make a transaction_get_result from the row...
2396
- return self->op_completed_with_callback(std::move(cb));
2397
- });
2398
- });
2460
+ }
2461
+ // make a transaction_get_result from the row...
2462
+ return self->op_completed_with_callback(std::move(cb));
2463
+ });
2399
2464
  }
2400
2465
 
2401
2466
  void
@@ -3798,6 +3863,17 @@ attempt_context_impl::create_staged_insert(const core::document_id& id,
3798
3863
  UNKNOWN,
3799
3864
  "before_staged_insert hook threw error");
3800
3865
  }
3866
+ // ExtThreadSafety invariant (couchbase-transactions-specs/transactions-extensions.md,
3867
+ // "ExtThreadSafety"): "no KV operations are staged but not sent in the BEGIN WORK" and
3868
+ // "no KV operations are performed regularly after queryMode is entered". insert()
3869
+ // reads the mode only after this op is counted in the wait-group, and set_query_mode()
3870
+ // drains all in-flight KV ops before issuing BEGIN WORK -- so reaching this staging
3871
+ // point with query mode already entered means that ordering has regressed and the
3872
+ // mutation would be silently lost at commit. Fires loudly in debug/FIT builds;
3873
+ // compiled out in release.
3874
+ assert(!op_list_.query_mode_entered() &&
3875
+ "ExtThreadSafety: KV insert staged after query mode entered -- would be missing "
3876
+ "from BEGIN WORK and lost at commit");
3801
3877
  CB_ATTEMPT_CTX_LOG_DEBUG(this, "about to insert staged doc {} with cas {}", id, cas);
3802
3878
  core::operations::mutate_in_request req{ id };
3803
3879
  const bool binary =
@@ -289,7 +289,11 @@ private:
289
289
  err.cause(TRANSACTION_ALREADY_COMMITTED);
290
290
  break;
291
291
  default:
292
- err.cause(UNKNOWN);
292
+ // Ops are only blocked once commit or rollback has begun (wait_and_block_ops), but the
293
+ // attempt state is published later -- and the empty-commit fast path never reaches
294
+ // COMMITTED at all. So a racing op can land here with the state still PENDING: report it
295
+ // as a concurrent operation rather than an opaque unknown cause.
296
+ err.cause(CONCURRENT_OPERATIONS_DETECTED_ON_SAME_DOCUMENT);
293
297
  }
294
298
  op_completed_with_error_no_cache(std::forward<Handler>(cb), std::make_exception_ptr(err));
295
299
  } catch (const transaction_operation_failed& e) {
@@ -32,7 +32,10 @@
32
32
  #include <couchbase/fmt/transaction_keyspace.hxx>
33
33
 
34
34
  #include <functional>
35
+ #include <future>
36
+ #include <list>
35
37
  #include <memory>
38
+ #include <thread>
36
39
  #include <utility>
37
40
 
38
41
  namespace couchbase::core::transactions
@@ -586,10 +589,17 @@ transactions_cleanup::add_collection(const couchbase::transactions::transaction_
586
589
  auto it = std::find(collections_.begin(), collections_.end(), keyspace);
587
590
  if (it == collections_.end()) {
588
591
  collections_.emplace_back(keyspace);
589
- // start cleaning right away
590
- lost_attempt_cleanup_workers_.emplace_back([this, keyspace = collections_.back()]() {
591
- this->clean_collection(keyspace);
592
- });
592
+ // Spawn only while running. stop() takes the worker handles out and joins them, so a
593
+ // handle appended after that point is one nothing has agreed to join -- and a joinable
594
+ // std::thread reaching its destructor terminates. Registering the keyspace is still
595
+ // right: start() spawns a worker for everything in collections_, so cleanup resumes for
596
+ // it on the next start.
597
+ if (running_) {
598
+ // start cleaning right away
599
+ lost_attempt_cleanup_workers_.emplace_back([this, keyspace = collections_.back()]() {
600
+ this->clean_collection(keyspace);
601
+ });
602
+ }
593
603
  }
594
604
  lock.unlock();
595
605
  CB_ATTEMPT_CLEANUP_LOG_DEBUG("added {} to lost transaction cleanup", keyspace);
@@ -599,8 +609,24 @@ transactions_cleanup::add_collection(const couchbase::transactions::transaction_
599
609
  void
600
610
  transactions_cleanup::start()
601
611
  {
602
- running_ =
603
- config_.cleanup_config.cleanup_client_attempts || config_.cleanup_config.cleanup_lost_attempts;
612
+ {
613
+ const std::unique_lock<std::mutex> lock(mutex_);
614
+ running_ = config_.cleanup_config.cleanup_client_attempts ||
615
+ config_.cleanup_config.cleanup_lost_attempts;
616
+
617
+ if (config_.cleanup_config.cleanup_lost_attempts) {
618
+ // Re-spawn a worker for every keyspace still registered from before a previous stop().
619
+ // add_collection() below cannot do this: it dedupes on collections_, so an
620
+ // already-registered keyspace is skipped and its lost-attempt cleanup stays dead for the
621
+ // rest of the process. notify_fork(prepare) calls stop() and notify_fork(parent) calls
622
+ // start() again, so every fork leaves exactly that state behind unless it's undone here.
623
+ for (const auto& keyspace : collections_) {
624
+ lost_attempt_cleanup_workers_.emplace_back([this, keyspace]() {
625
+ this->clean_collection(keyspace);
626
+ });
627
+ }
628
+ }
629
+ }
604
630
  if (config_.cleanup_config.cleanup_client_attempts) {
605
631
  cleanup_thr_ = std::thread([this] {
606
632
  attempts_loop();
@@ -628,7 +654,16 @@ transactions_cleanup::stop()
628
654
  cleanup_thr_.join();
629
655
  CB_ATTEMPT_CLEANUP_LOG_DEBUG("cleanup attempt thread closed");
630
656
  }
631
- for (auto& t : lost_attempt_cleanup_workers_) {
657
+ // Take the handles out under mutex_, then join with the lock released: add_collection() and
658
+ // start() append to this list while holding mutex_, so walking it live would race with them;
659
+ // and the workers themselves take mutex_ (is_running(), interruptable_wait()), so joining
660
+ // while still holding it would deadlock.
661
+ std::list<std::thread> workers;
662
+ {
663
+ const std::unique_lock<std::mutex> lock(mutex_);
664
+ workers.swap(lost_attempt_cleanup_workers_);
665
+ }
666
+ for (auto& t : workers) {
632
667
  CB_LOST_ATTEMPT_CLEANUP_LOG_DEBUG("shutting down all lost attempt threads...");
633
668
  if (t.joinable()) {
634
669
  t.join();