Issue 7462 - Fix SERIAL LOCK and entry cache lock ordering on BDB dea… - #7463
Issue 7462 - Fix SERIAL LOCK and entry cache lock ordering on BDB dea…#7463vashirov wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- The retry paths now contain a lot of duplicated logic for unlocking entry-cache locks, aborting the txn, sleeping, then re-locking (with similar error handling); consider factoring this pattern into small helper functions to reduce complexity and lower the risk of divergence between add/modify/delete/modrdn implementations.
- You’ve introduced several
*_lockedflags that are updated in many branches; it might be worth double-checking and consolidating the ownership/lifecycle handling (e.g., via small helpers that paircache_lock_entrywith flag updates) to make it harder to accidentally get the flag and the actual lock state out of sync in future changes. - In
ldbm_back_modify, the new error log on failed re-lock ("Failed to re-lock entry after deadlock retry") omits theconn_id/op_idcontext that the other new log messages include; consider adding those identifiers for consistency and easier debugging.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The retry paths now contain a lot of duplicated logic for unlocking entry-cache locks, aborting the txn, sleeping, then re-locking (with similar error handling); consider factoring this pattern into small helper functions to reduce complexity and lower the risk of divergence between add/modify/delete/modrdn implementations.
- You’ve introduced several `*_locked` flags that are updated in many branches; it might be worth double-checking and consolidating the ownership/lifecycle handling (e.g., via small helpers that pair `cache_lock_entry` with flag updates) to make it harder to accidentally get the flag and the actual lock state out of sync in future changes.
- In `ldbm_back_modify`, the new error log on failed re-lock (`"Failed to re-lock entry after deadlock retry"`) omits the `conn_id`/`op_id` context that the other new log messages include; consider adding those identifiers for consistency and easier debugging.
## Individual Comments
### Comment 1
<location path="ldap/servers/slapd/back-ldbm/ldbm_modify.c" line_range="708-709" />
<code_context>
+ if (retry_count > 0 && e) {
+ int lock_rc = cache_lock_entry(&inst->inst_cache, e);
+ if (lock_rc != 0) {
+ slapi_log_err(SLAPI_LOG_ERR, "ldbm_back_modify",
+ "Failed to re-lock entry after deadlock "
+ "retry (rc=%d)\n", lock_rc);
+ CACHE_RETURN(&inst->inst_cache, &e);
</code_context>
<issue_to_address>
**suggestion:** Log message for re-lock failure in modify lacks connection/operation context, unlike the other new log messages.
To keep logging consistent and aid correlation in production, please include `conn_id` and `op_id` in this re-lock failure message as well, similar to the re-lock logs in `ldbm_back_add`, `ldbm_back_delete`, and `ldbm_back_modrdn`.
Suggested implementation:
```c
int lock_rc = cache_lock_entry(&inst->inst_cache, e);
if (lock_rc != 0) {
slapi_log_err(SLAPI_LOG_ERR, "ldbm_back_modify",
"conn=%" PRIu64 " op=%d Failed to re-lock entry after deadlock "
"retry (rc=%d)\n",
conn_id, op_id, lock_rc);
```
This change assumes that `conn_id` and `op_id` are already available in this scope as in the other logging sites you referenced, and that `<inttypes.h>`/`PRIu64` are already included for the file (as is common elsewhere in this codebase). If the existing re-lock logs in `ldbm_back_add`, `ldbm_back_delete`, or `ldbm_back_modrdn` use a slightly different format string (e.g. including `ldbm_back_modify -` or different spacing), you should align this message to match that exact pattern for consistency.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
e2a549b to
8025e13
Compare
|
Congratulations! One of the builds has completed. 🍾 You can install the built RPMs by following these steps:
Please note that the RPMs should be used only in a testing environment. |
progier389
left a comment
There was a problem hiding this comment.
Looks good except a minor point (use bool for the flags instead of int32_t)
| Connection *pb_conn; | ||
| int32_t parent_op = 0; | ||
| int32_t betxn_callback_fails = 0; /* if a BETXN fails we need to revert entry cache */ | ||
| int32_t e_locked = 0; /* non-zero when entry 'e' cache lock is held */ |
There was a problem hiding this comment.
Should better use bool and true/false
droideck
left a comment
There was a problem hiding this comment.
Overall, looks great!
Regarding testing, do you think it's possible to reproduce it via stress test?
Something that spawns N writer threads doing add/mod/del under deferred-memberof probably would exercise the retry path. But that's a stretch and it's not a blocker for the PR merge.
| ldap_result_code = -1; | ||
| goto error_return; /* error result sent by find_entry2modify() */ | ||
| } | ||
| e_locked = 1; |
There was a problem hiding this comment.
Do I understand correctly that for MANAGE_ENTRY_BEFORE_DBLOCK case on iter 1 we acquire E first and then SERIAL LOCK? But then on iter 2 -- it's another way around (SERIAL LOCK first and then E)?
IIUC, even though very rarely (and especially because MANAGE_ENTRY_BEFORE_DBLOCK is not a default mode but also experimental mode) but we still might have the AB-BA deadlock here.
There was a problem hiding this comment.
Perhaps when (!MANAGE_ENTRY_BEFORE_DBLOCK) we should still lock the entry if it is not locked already (near line 721). I think that will fix the ordering when using that db opt level.
if (!MANAGE_ENTRY_BEFORE_DBLOCK) {
...
} else if (e && !e_locked) {
cache_lock_entry(&inst->inst_cache, e);
e_locked = 1;
}
| if (txn.back_txn_txn && (txn.back_txn_txn != parent_txn)) { | ||
| /* Don't release SERIAL LOCK */ | ||
| dblayer_txn_abort_ext(li, &txn, PR_FALSE); | ||
| /* |
There was a problem hiding this comment.
I wonder if there is not a confusion between backend lock and db_env lock.
Initially the backend lock was held during all lmdb_add and db_env release during retry/sleep.
With the move of plugins in betxn (#351) it was changed from dblayer_txn_abort (release the db_env) to dblayer_txn_abort_ext(...FALSE) (to not release the db_env) but the comment was about 'Serial Lock' that in my mind means backend lock.
Of course we do not want to release the backend lock for a retry but it looks fine to me to release db_env.
What is not clear to me is the impact of releasing it. IIUC it allows others txn but on different backend. Is it the expected benefit ?
|
Hey @vashirov, could you address the outstanding review comments and rebase? Thanks! |
|
@aadhikar, it's ready to be merged, but waiting for slapi-nis PR to be merged https://codeberg.org/freeipa/slapi-nis/pulls/71 at the same time, otherwise this will break FreeIPA nightly CI |
…dlock retry Bug Description: In IPA context, server can freeze under heavy write load (add/mod/del users and hosts, deferred memberof enabled). I found 2 issues: 1. In deadlock retry path, a thread grabs SERIAL LOCK, during deadlock sleeps still holding the SERIAL LOCK. Every other writer thread waits for that sleep to finish. 2. After fixing the first issue, I encountered lock ordering issues with SERIAL LOCK and entry locks. On retry Thread A holds entry lock from the previous attempt, tries to grab SERIAL LOCK. Thread B holds SERIAL LOCK, wants the same entry lock. Neither can proceed, resulting in a deadlock. Fix Description: 1. Release SERIAL LOCK before backoff sleep on BDB deadlock retry and cache lock retry paths. 2. Fix AB-BA deadlock between SERIAL LOCK and entry cache locks by releasing/re-acquiring entry locks around retry. Fixes: 389ds#7462
|
|
||
| /* Re-lock parent after SERIAL LOCK to maintain lock ordering */ | ||
| if (retry_count > 0 && parent_found && parent_modify_c.old_entry) { | ||
| int lock_rc = cache_lock_entry(&inst->inst_cache, |
There was a problem hiding this comment.
IIUC, if a concurrent modify touches the parent during our backoff, modify_switch_entries() goes through cache_replace(), which marks the old backentry ENTRY_STATE_DELETED. Our pointer still references that old backentry, so cache_lock_entry() here returns RETRY_CACHE_LOCK and we turn it into LDAP_OPERATIONS_ERROR.
Previously, the concurrent writer just blocked on the lock and both operations succeeded; now the retrying add fails with err=1. And since a deadlock retry implies a concurrent writer, this fires exactly on the collision path the patch is smoothing, unless I miss something.
Same shape in modify, delete, and modrdn.
Maybe, on a RETRY_CACHE_LOCK we can refetch the entry by DN and restart the attempt?
And fail only if the entry is genuinely gone (RETRY_CACHE_LOCK also covers real deletion, so the refetch distinguishes the two for free).
| * Entries stay refcounted in cache, so pointers remain valid. | ||
| */ | ||
| if (e_locked) { | ||
| cache_unlock_entry(&inst->inst_cache, e); |
There was a problem hiding this comment.
The preamble releases e, parent, and newparent, but moddn_get_children() (inside the run-once block) takes cache_lock_entry() on every cached child
389-ds-base/ldap/servers/slapd/back-ldbm/ldbm_modrdn.c
Lines 2186 to 2191 in 65a1afc
And those are only released in the completion paths
389-ds-base/ldap/servers/slapd/back-ldbm/ldbm_modrdn.c
Lines 1301 to 1305 in 65a1afc
So a subtree rename sleeps holding N child monitors and then waits for the serial lock, while a modify of one of those children holds the serial lock and waits for the child's monitor. IIUC, that is the same AB-BA this patch removes for the top entries, moved to the children.
And cached tombstone children count too, so it is not limited to big subtrees.
Possible, we can release the child locks in the preamble as well, so nothing entry-level is held across the backoff, and re-lock (or refetch) them after the serial lock is re-acquired. What do you think?
| slapi_entry_free(ent); | ||
| slapi_pblock_set(pb, SLAPI_DELETE_EXISTING_ENTRY, NULL); | ||
| } | ||
| slapi_pblock_set(pb, SLAPI_DELETE_EXISTING_ENTRY, slapi_entry_dup(e->ep_entry)); |
There was a problem hiding this comment.
This dup (and the SDN read at :231) runs after e was unlocked a few lines above. The refcount keeps the backentry struct alive, but not ep_entry: the URP path at :421 does slapi_entry_free(e->ep_entry) and replaces it in place, under a lock we no longer hold. A replicated delete of the same entry during our backoff makes this read freed memory. Admittedly hard to hit, but the fix is cheap.
| parent = id2entry(be, pid, &txn, &retval); | ||
| if (parent && (cache_retry = cache_lock_entry(&inst->inst_cache, parent))) { | ||
| /* Failed to obtain parent entry's entry lock */ | ||
| if (cache_retry == RETRY_CACHE_LOCK && |
There was a problem hiding this comment.
Small commit note...
This restart triggers on RETRY_CACHE_LOCK, hence this part is backend-independent, so it is live on LMDB. And it looks like an improvement! (the old code slept holding what on LMDB is the single write transaction). Worth reflecting in the commit message, IMO.:)
|
After my dive into the DB retry stuff, I decided to dig a bit deeper into this one... (as it might help another case with memberof massive writes) And... Well, you see what came out of it... Hopefully, some of the findings are worthy though! |
…dlock retry
Bug Description:
In IPA context, server can freeze under heavy write load (add/mod/del users and hosts, deferred memberof enabled).
I found 2 issues:
Fix Description:
Fixes: #7462
Summary by Sourcery
Adjust BDB transaction retry handling to avoid deadlocks involving the global SERIAL LOCK and entry cache locks during high-write workloads.
Bug Fixes:
Enhancements: