Skip to content

fix(#5026): HTTP transaction session lifecycle - remove on commit/rollback, sweep-race re-validation, ownership#5175

Merged
robfrank merged 5 commits into
mainfrom
fix/5026-http-tx-session-lifecycle
Jul 9, 2026
Merged

fix(#5026): HTTP transaction session lifecycle - remove on commit/rollback, sweep-race re-validation, ownership#5175
robfrank merged 5 commits into
mainfrom
fix/5026-http-tx-session-lifecycle

Conversation

@robfrank

@robfrank robfrank commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #5026 (2026-07 server audit). Three related defects in the server-side HTTP transaction-session lifecycle.

Defect 1 (HIGH) - /commit and /rollback never removed the server-side session

PostCommitHandler/PostRollbackHandler only stripped the response header; the session stayed registered until the idle sweep. A stale session id kept resolving, so a follow-up write ran in an implicit auto-committing transaction while the client believed it was inside a transaction it could still roll back, and a retried /commit//rollback returned HTTP 500.

Fix:

  • Both handlers unregister the session after commit/rollback and guard the commit/rollback with isTransactionActive(), so a retried call is an idempotent 204 (never 500).
  • DatabaseAbstractHandler.setTransactionInThreadLocal rejects an unresolved session id on a write-capable handler (requiresTransaction()) with an explicit HttpSessionException mapped to HTTP 404, instead of silently falling through to an auto-committing transaction. Read-only (GET /query) and the transaction endpoints (/begin, /commit, /rollback) degrade to a session-less request, preserving read-after-commit and idempotent retries.

Defect 2 (MEDIUM) - idle-sweep vs in-flight request race leaked a transaction

The sweep could cancelIfIdle() (rollback) and remove a session between getSessionById() and HttpSession.execute() acquiring the session lock; the request then ran on the rolled-back TransactionContext, and because the session was already untracked nothing ever committed/rolled back the fresh transaction it began.

Fix: HttpSession.execute() re-validates, under the session lock, that the session is still registered before running the callback; otherwise it throws (no callback runs on a swept transaction).

Defect 3 (MEDIUM) - ownership checked by name only, and after attach

getSessionById ignored the user param and ownership relied on ServerSecurityUser.equals (by name), so a dropped-and-recreated same-name principal could adopt the prior principal's open session.

Fix:

  • HttpSessionManager.getSessionById enforces ownership before attach (returns null for a non-owner).
  • New removeSessionsForUser(name); ServerSecurity.dropUser/setUserPassword/updateUser invalidate the principal's live HTTP sessions (rolling back their transactions), so a recreated same-name principal starts clean.

Acceptance criteria

  • After /commit//rollback the session id is no longer resolvable; a follow-up write returns an explicit error, not a silent implicit-tx commit.
  • A retried /commit//rollback is idempotent (204, no 500).
  • An idle sweep cannot roll back a session a request is actively using; no leaked transaction under the sweep-vs-request race.
  • A recreated-same-name user cannot adopt the prior principal's session.

Tests

  • Issue5026HttpSessionLifecycleTest (unit): execute re-validation, ownership, removeSessionsForUser, recreated-same-name adoption.
  • Issue5026TransactionSessionLifecycleIT (HTTP): commit/rollback remove the session, stale-session write rejected (404), retried commit/rollback idempotent (204), drop-user invalidates sessions so a recreated same-name user cannot adopt.

All new tests pass; existing session/security tests remain green (HTTPTransactionIT, Issue4141SessionManagementIT, HttpSessionTimeoutRaceTest, HTTPAuthSessionIT, UserManagementIT, AutoCommitParameterTest, ServerSecurityUsersConcurrencyTest). No wire-format change; server module only.

@mergify

mergify Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@codacy-production

codacy-production Bot commented Jul 9, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 20 complexity

Metric Results
Complexity 20

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses several defects in the server-side HTTP transaction-session lifecycle (Issue #5026). It ensures that REST /commit and /rollback properly remove server-side sessions, re-validates session registration under lock to prevent races with idle-timeout sweeps, and enforces session ownership by invalidating live sessions when users are dropped, updated, or have their passwords changed. Additionally, a potential NullPointerException was identified in HttpSessionManager.getSessionById() if session.user is null, which should be guarded against to prevent runtime crashes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

return null;
// Enforce ownership BEFORE the caller attaches the session to the thread context: a session must only be
// resolvable by the principal that opened it, so another user cannot adopt (and commit) its transaction.
if (user != null && !session.user.equals(user))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If session.user is null (which is checked and handled defensively elsewhere in this class, such as on line 157), calling session.user.equals(user) will throw a NullPointerException. Guarding against a null session.user prevents potential runtime crashes when resolving sessions.

Suggested change
if (user != null && !session.user.equals(user))
if (user != null && (session.user == null || !session.user.equals(user)))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - applied in 7cf87f9. getSessionById now guards session.user == null before equals, matching removeSessionsForUser; an ownerless session is never resolvable by an authenticated principal.

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

PR Review — #5175 HTTP transaction session lifecycle

Solid, well-motivated fix. The three defects are real, the concurrency reasoning is carefully documented, and the sweep-vs-request race fix (re-validating isSessionRegistered under the per-session lock, combined with checkSessionsValidity doing both cancelIfIdle() and it.remove() inside the manager write lock) is correct: once execute() holds the session lock and passes the registration check, the sweep can no longer roll it back (its cancelIfIdle() tryLock() fails → BUSY). Nice work, and the tests map cleanly onto the acceptance criteria.

A few points, one of which I think is a genuine regression worth addressing before merge.

1. (High) removeSession(exchange) on /commit /rollback bypasses ownership → cross-user transaction leak

DatabaseAbstractHandler.removeSession(exchange) removes the session by the raw header id with no ownership check, and PostCommitHandler/PostRollbackHandler call it unconditionally, even on the session-less degrade path. Trace an authenticated user B who knows user A's session id and posts POST /commit (or /rollback) with arcadedb-session-id: <A's id>:

  • setTransactionInThreadLocalgetSessionById(B, A_id) returns null (ownership rejects it), and since requiresTransaction() is false for commit/rollback it degrades session-less (no throw).
  • PostCommitHandler.execute: database.isTransactionActive() is false (session-less) → no commit.
  • removeSession(exchange) still runs → sessions.remove(A_id) removes A's session from the map but never calls cancel().

A's TransactionContext is now untracked but still open — the idle sweep can no longer reclaim it (it is gone from the map), so it leaks until GC, holding whatever page/lock resources it had. Pre-PR these handlers never called removeSession, so this is newly introduced. The session id is an unguessable UUID so exploitability is limited, but it defeats the ownership model this PR is otherwise establishing, and it is a plain correctness bug for any shared-database scenario.

Suggested fix: only remove a session the caller actually owns, e.g. gate on the resolved session (removeSession only when activeSession != null), or have removeSession(exchange, user) re-check getSessionById(user, id) != null before removing. That also keeps the normal owned path (tx already committed/rolled back before removal) intact.

2. (Minor) POST /query does not degrade session-less like GET /query

The comment in setTransactionInThreadLocal states read-only handlers "degrade to a session-less request, keeping read-after-commit working", but only GetQueryHandler overrides requiresTransaction()false. PostQueryHandler inherits the default true, so a stale/unresolved session on POST /query throws 404 rather than degrading. If POST /query is intended to be read-only, consider overriding requiresTransaction()false for consistency with the stated design (not a regression — prior behavior was a hard 401 — but the read-after-commit goal is not met for POST /query).

3. (Minor) Status code change 401 → 404 for a stale session

setTransactionInThreadLocal previously set StatusCodes.UNAUTHORIZED (401); the write-capable path now surfaces HttpSessionException → 404. 404 is arguably more correct, but this is a client-visible behavior change worth a changelog/doc note for API consumers that branch on 401.

4. (Minor) Test assertion is too broad

Issue5026HttpSessionLifecycleTest.executeRefusesToRunOnAnUnregisteredSession asserts .isInstanceOf(Exception.class). Tightening to HttpSessionException would pin the contract and catch a regression where some other exception is thrown first (e.g. an NPE) while still passing.

5. (Nit) Defensive: getSessionById ownership check

if (user != null && !session.user.equals(user)) NPEs if user is non-null while session.user is null. In practice security is all-on or all-off so this cannot occur today, but a null-guard on session.user (or documenting the invariant) would be cheap insurance.

Things I checked and liked

  • Catch ordering in AbstractServerHttpHandler correctly places HttpSessionException before TransactionException (it is a subclass) — 404 is reached, not the 500 fallthrough.
  • removeSessionsForUser unregisters under the write lock then cancel()s outside it (avoiding holding the manager lock across the unbounded per-session wait) — the same discipline used by close().
  • Idempotent-retry guard (isTransactionActive() before commit/rollback) and the invalidateHttpSessions hook on dropUser/setUserPassword/updateUser (all explicit admin actions via PutUserHandler) are appropriately scoped.

Overall: address #1 (ownership on removeSession) and I would be comfortable with this. The rest are minor/optional.

Reviewed with Claude Code.

@robfrank

robfrank commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review. Addressed in 6913686 and 7cf87f9:

1. (High) removeSession bypassed ownership → cross-user orphan. Real bug, fixed. removeSession(exchange, user) now resolves via getSessionById(user, id) and only removes a session the caller actually owns; a foreign or already-gone id is a benign no-op, so another principal's /commit//rollback can no longer evict/orphan a session. New regression test commitWithAnotherUsersSessionDoesNotOrphanIt asserts root's session survives (and stays usable) after another user posts /commit with root's id.

4. (Minor) Test assertion too broad. Tightened executeRefusesToRunOnAnUnregisteredSession to isInstanceOf(HttpSessionException.class).

5. (Nit) getSessionById null-guard. Already added (7cf87f9, also flagged by Gemini): user != null && (session.user == null || !session.user.equals(user)).

2. (Minor) POST /query does not degrade session-less. Left as-is intentionally: PostQueryHandler extends PostCommandHandler, and flipping requiresTransaction()false there also changes its auto-transaction wrapping semantics, which is out of scope for this fix. As you note it is not a regression (prior behavior was a hard 401/500). Can be a follow-up if we want POST /query read-after-commit parity with GET /query.

3. (Minor) 401 → 404 for a stale session. Intentional and, as you say, more correct (the id is absent, not an auth failure). Noted in the tracking doc; worth a changelog line at release.

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Code Review: PR #5175 - HTTP transaction session lifecycle (#5026)

Thorough, well-documented fix. The three defects are real, the root-cause analysis is accurate, and the locking discipline is careful. Below are strengths, then discussion points and a few gaps.

What's done well

  • Correct locking discipline. removeSessionsForUser unregisters under the manager write lock, then calls cancel() (which waits on the per-session lock) outside it - matching the pattern already used by close(), and avoiding holding the manager lock while blocking on an in-flight command.
  • Deadlock-free sweep race fix. The execute() re-validation works precisely because checkSessionsValidity performs cancelIfIdle() + it.remove() atomically under the manager write lock, and cancelIfIdle uses a non-blocking tryLock() on the session. So a request either sees the session still registered (sweep skipped it as BUSY) or sees it gone.
  • Catch-ordering is correct. HttpSessionException extends TransactionException, and AbstractServerHttpHandler catches the subclass first - the comment even calls this out. 404 vs a silent implicit-commit is the right semantic.
  • Idempotency guard is right. isTransactionActive() before commit/rollback, combined with requiresTransaction()==false on those endpoints (so a removed session degrades session-less rather than 404), gives the retried-204 behavior, and the ITs verify it end to end.
  • Good null-safety for embedded use (invalidateHttpSessions no-ops when the HTTP server is absent), and session.user/id are public final so the lock-free reads in the manager are safe.

Discussion points

  1. Behavior change for reads (worth confirming intent). Previously every handler with an unresolvable session id returned 401. Now read handlers (GET /query, requiresTransaction()==false) silently degrade to a session-less request and return 200. A client that passed a session id specifically to read-its-own-writes within an open transaction will no longer be told the session expired - the read just runs outside the session. That is defensible (and is what "read-after-commit preserved" refers to), but it is a semantic change from a hard 401 to silent degradation. Consider documenting it, and note there is no HTTP-level test asserting the read-degradation path (the ITs only exercise writes) - the "read-after-commit preserved" claim is currently untested at the HTTP layer.

  2. updateUser invalidation may be too aggressive (low). dropUser and setUserPassword invalidating sessions is clearly correct. updateUser unconditionally rolling back all of a user's live transactions is heavier: a benign metadata update via PUT /user will force-rollback unrelated in-flight transactions for that principal. Consider scoping the invalidation to changes that actually affect authz/credentials, or at least documenting that PUT /user terminates the user's open transactions.

  3. Ownership still ultimately rides on equals-by-name + the invalidation hooks (defense-in-depth, low). The recreated-same-name protection depends entirely on every user-removal/mutation path routing through dropUser/updateUser/setUserPassword. Any other path that replaces the users map (e.g. a reload of users from disk, or a future admin flow) would leave stale sessions adoptable by a same-name principal, since getSessionById itself compares by name. A short comment pointing at the single invalidation entry point (or centralizing it) would make that invariant harder to break later.

Minor / nits

  1. Per-command overhead (perf mantra). execute() now acquires the manager read lock (isSessionRegistered, a map lookup) on every session command, inside the session lock. Negligible in practice, but it could be avoided entirely by having removal flip a volatile boolean on the HttpSession itself (set in removeSession/cancelIfIdle), which execute() already reads under the session lock - no manager lock, no map lookup on the hot path.

  2. Import ordering in ServerSecurity.java: import com.arcadedb.server.http.HttpServer; is inserted between DefaultConsoleReader and ServerException, breaking the existing alphabetical grouping.

Test coverage

Good overall: unit tests exercise the manager/session directly and the IT covers commit/rollback removal, stale-write 404, idempotent retries, and drop-user invalidation across every server. Gaps: (a) no HTTP test for read (GET /query) degradation with a stale session id; (b) no direct test of the sweep-vs-request race under real concurrency (the unit test simulates the removal ordering rather than racing threads, which is a reasonable simplification but leaves the actual interleaving uncovered).

Overall this is a solid, carefully-reasoned fix. The main thing I'd want an explicit decision on is point #1 (silent read degradation vs. an explicit signal), plus a test for it.

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Code Review - PR #5175 (fix #5026: HTTP transaction session lifecycle)

Reviewed against CLAUDE.md conventions. Overall this is a strong, well-scoped fix: the three defects are real, the reasoning in the Javadoc/comments is excellent, lock ordering is consistent (session lock then manager lock everywhere; the sweep uses non-blocking tryLock, so no deadlock), and the test coverage (unit + HTTP IT) is genuinely good. A few things worth considering before merge.

Correctness / concurrency

  1. (Medium - liveness) Unbounded cancel() wait held under the ServerSecurity monitor. dropUser/updateUser/setUserPassword are synchronized, and they now call invalidateHttpSessions -> removeSessionsForUser -> session.cancel(). cancel() waits unbounded on the per-session lock for any in-flight command (by design, per its Javadoc). removeSessionsForUser correctly removes from the map outside the manager lock, but the cancel() still runs while the calling admin method holds the ServerSecurity intrinsic monitor. If an invalidated session has a long-running in-flight command, that admin call blocks for its full duration, and any caller that serializes on the security monitor (there is an explicit synchronized(server.getSecurity()) pattern noted at ServerSecurity.java:532) stalls with it. Admin ops are infrequent and commands are usually short, so this is low-probability, but consider snapshotting the owned sessions and running the cancel() loop outside the synchronized block, or documenting the trade-off.

  2. (Low - behavioral) updateUser invalidates sessions on any update, including database-grant-only edits. PutUserHandler also routes databases (grant) changes through updateUser, so editing a user grants now rolls back that user open HTTP transactions with a 404 on their next call. That is defensible for authorization correctness, but it means uncommitted work is silently discarded on a grant edit, not just a password change. Worth confirming this is intended (and calling it out in the docs file, which currently frames it around drop/recreate and password).

Behavioral note

  1. Read handlers now silently degrade a stale session to a session-less request (previously 401). For GET /query this is the intended read-after-commit behavior, but a client passing an expired session id to a read no longer learns the session expired, it just gets a fresh implicit read. Fine, but worth a line in the docs so it is a documented contract, not a surprise. (No existing tests asserted the old 401, so nothing breaks.)

Tests

  1. (Minor) recreatedSameNameUserCannotAdoptPriorSession does not test what its name implies. Ownership is by name (ServerSecurityUser.equals compares name), so recreatedAlice equals the original alice. The test passes only because removeSessionsForUser already removed the session from the map, the ownership check plays no role here. The real defense against same-name adoption is invalidation-on-drop, which is correct, but the assertion would pass even if the ownership gate were removed. Consider adding a companion assertion (e.g. that a still-registered session would resolve for a same-name principal) to document that removal, not identity, is the guard.

Otherwise the IT coverage (commit/rollback remove session, stale write to 404, idempotent retry to 204, foreign-session commit is a no-op, drop-user invalidation) is exactly right, and the execute() re-validation happy-path/negative-path pair is a nice touch.

Nits

  • ServerSecurity.java import: com.arcadedb.server.http.HttpServer is placed before com.arcadedb.server.ServerException, which is out of alphabetical order.
  • Nice catch putting the HttpSessionException catch arm before TransactionException in AbstractServerHttpHandler (since it extends it), the comment makes the ordering intent clear.

Nothing here is a blocker; items 1 and 2 are the ones I would want a second look at. Good work.

Automated review by Claude.

@robfrank

robfrank commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks - second round addressed in 1d799aa:

1. Silent read degradation + missing HTTP test. Added staleSessionOnReadDegradesToSessionlessRead: after /commit removes the session, a GET /query carrying the stale id returns 200 with the committed row (read-after-commit preserved at the HTTP layer). The 401→200-degradation for reads is intentional; noted in the tracking doc for the changelog.

2. updateUser invalidation too aggressive. Agreed - removed. A metadata update no longer force-rolls-back the principal's in-flight transactions; per-request authorization is still re-checked on every command, so a narrowed grant is enforced without tearing down unrelated work. Only dropUser and setUserPassword invalidate live sessions, matching the issue's Defect 3 scope.

3. Ownership invariant / centralization. Added a comment on getSessionById documenting that recreated-same-name protection depends on removeSessionsForUser being invoked from every user removal/mutation path (dropUser/setUserPassword), so any future users-map-replacing path must also invalidate here.

4. Per-command isSessionRegistered overhead. Kept the manager read-lock lookup as the single source of truth for "is this session still live". A volatile boolean on HttpSession would need to be flipped consistently at every removal site (removeSession, removeSessionsForUser, the sweep's it.remove()), and a missed site would leave a stale flag - a correctness risk that outweighs saving an uncontended read-lock map lookup on a path already inside the per-session lock. Happy to revisit if it ever shows up in a profile.

5. Import ordering in ServerSecurity. Fixed - http.HttpServer now sorts after the server.* classes.

3 (behavior)/concurrency test gap: the sweep-vs-request race is covered deterministically (removal ordering) rather than by racing real threads; the existing HttpSessionTimeoutRaceTest covers the thread-racing BUSY path. Left as-is.

@robfrank

robfrank commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks - addressed in daeda6b. (Note: this round reviewed 6913686, before 1d799aa, so items 2, 3-docs and the import nit were already fixed there.)

1. (Medium, liveness) Unbounded cancel() under the security monitor. Fixed. dropUser now mutates the users map under the monitor but calls invalidateHttpSessions (which can block on an in-flight command via cancel()) OUTSIDE the synchronized scope, so a long-running victim command no longer stalls admin ops serialized on the security monitor. setUserPassword is not synchronized, so its invalidation was already outside any monitor. Documented the constraint on invalidateHttpSessions.

2. updateUser invalidation. Already removed in 1d799aa - updateUser no longer touches live sessions; only dropUser/setUserPassword do.

3. Read degradation contract. Documented in the tracking doc (behavior-changes section) and covered by the new staleSessionOnReadDegradesToSessionlessRead HTTP test (also added in 1d799aa).

4. (Minor) recreatedSameNameUserCannotAdoptPriorSession didn't exercise the real guard. Good point - added a companion assertion that a same-name principal WOULD resolve the session before invalidation, pinning that removal-on-drop (not the identity check) is the guard.

Nit (import order). Already fixed in 1d799aa - http.HttpServer now sorts after the server.* classes.

All new + existing session/security tests remain green.

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review: PR #5175 - HTTP transaction session lifecycle (#5026)

Thorough, well-documented fix. The three defects are clearly separated, each with a targeted change and regression coverage, and the concurrency reasoning in the comments is genuinely careful. I checked the lock-ordering and the degradation paths and they hold up. A few notes below, one of which I think is worth addressing before merge.

What's solid

  • Lock ordering is correct. execute() acquires the session lock, then briefly takes the manager read lock (isSessionRegistered). The reclaim paths (checkSessionsValidity uses tryLock() and returns BUSY; removeSessionsForUser/close() call cancel() outside the manager write lock) never block-acquire a session lock while holding the manager write lock, so there is no deadlock. The re-validation + tryLock-based sweep together genuinely close the Defect 2 race.
  • Idempotency of retried /commit and /rollback (guarded by isTransactionActive(), degrading to session-less via requiresTransaction()==false) is correct and covered by tests.
  • Read-after-commit degradation is preserved and pinned by staleSessionOnReadDegradesToSessionlessRead.
  • Good defensive touches: null-safe ownership check, ownership-gated removeSession so a foreign id cannot evict/orphan a session, invalidateHttpSessions no-op when the HTTP server is not running (embedded).

Main concern (medium) - Defect 3 protection is leader-only in HA

The recreated-same-name defense relies on removeSessionsForUser being called from every path that removes/replaces a user. It is wired into the direct dropUser/setUserPassword API calls, but not into the two paths that replace the whole users map:

  • applyReplicatedUsers(...) (ServerSecurity.java:564, the Raft apply path invoked on every peer for a SECURITY_USERS_ENTRY) swaps this.users without invalidating any sessions.
  • loadUsers() / reload (ServerSecurity.java:154) does the same.

Consequence: when a user is dropped on the leader, followers apply the change via applyReplicatedUsers and their follower-local HTTP sessions for that user are not invalidated. Because ownership compares by name (ServerSecurityUser.equals), a request hitting that follower with the old session id and recreated same-name credentials will still resolve the prior session - exactly the adoption the PR set out to prevent. Sessions are node-local and /begin can run on a follower (reads), so this is reachable, not theoretical.

The invariant comment you added in getSessionById ("Any future path that replaces the users map ... must also invalidate the affected sessions") is already violated by applyReplicatedUsers, which is an existing path, not a future one. Suggest diffing the previous vs new user set inside applyReplicatedUsers (and reload) and calling removeSessionsForUser(name) for every user that was dropped or whose password/config changed. The IT passes today only because it drives dropUser directly on the same node that holds the session.

Minor

  • Redundant lookup (DatabaseAbstractHandler.removeSession, line 300): inside /commit//rollback the session was already resolved into activeSession by setTransactionInThreadLocal; re-resolving by header here costs an extra read+write lock round-trip. Minor, but you could thread the resolved session through instead.
  • Hot-path cost: isSessionRegistered() adds a manager read-lock acquisition to every in-session command. It is a shared lock so contention is low, but worth being aware of on the write hot path.
  • Compat note: a stale-session write now returns 404 where the previous code set 401 (StatusCodes.UNAUTHORIZED). This is arguably more correct, but it is a client-visible status change - worth a line in the changelog/release notes. (The docs/5026-*.md note is a nice touch, though it lives in docs/ rather than the usual changelog.)
  • Test note: Defect 2 is exercised by simulating the sweep (manual removeSession) rather than a real concurrent sweep. Fine as a regression guard given how hard the true race is to reproduce deterministically - just flagging that it does not prove the timing window itself.

Style/convention

Consistent with the codebase - final on params/locals, no fully-qualified names, AssertJ assertThat(...), IT suffix on the HTTP test, Apache headers present. No new dependencies.

Nice work overall - the core fix is correct; the HA replication path is the one gap I would close before merge.

Automated review; please verify the HA point against your understanding of session/replication topology.

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review: PR #5175 - HTTP transaction session lifecycle (#5026)

Overall this is a strong, well-scoped fix. The three defects are real, the reasoning is documented clearly in-code, the lock ordering is sound (invalidation releases the manager lock before the blocking cancel()), and the iterative review-response commits already tightened the sharp edges (ownership-gated removal, null-safe ownership check, moving invalidateHttpSessions outside the ServerSecurity monitor). Test coverage is good at both the unit and HTTP-e2e level. A few points below, mostly minor.

Correctness / behavior

  1. POST /query does not degrade like GET /query (inconsistency). The read-vs-write discriminator is requiresTransaction(). PostQueryHandler extends PostCommandHandler, which leaves requiresTransaction() at the default true, so a stale/expired session id on POST /query (a read endpoint) returns 404, while the identical logical read via GET /query degrades to a session-less 200. The doc and comments state "Read handlers (GET /query) degrade," but POST /query is also a read and won't. Consider keying the degradation on an explicit read/write flag rather than requiresTransaction(), or at least documenting that POST /query with a stale session returns 404. Not a regression (it previously 401'd), but the read-after-commit contract is only half-delivered.

  2. dropUser recreate-race can kill a fresh same-name session. dropUser mutates the users map under synchronized(this) and then calls invalidateHttpSessions(userName) outside the monitor (correct, to avoid blocking on cancel()). But in that window another thread could createUser the same name and begin a new session; removeSessionsForUser matches purely by name, so the just-created principal's brand-new session would then be rolled back too. Very narrow, and arguably acceptable given name-based identity, but worth a comment acknowledging it.

Tests

  1. executeRefusesToRunOnAnUnregisteredSession simulates the sweep-vs-request race deterministically (manual rollback() + removeSession() before execute()) rather than exercising real concurrency. That is a reasonable and stable choice, but it does not prove the actual tryLock/gap ordering that Defect 2 is about. A comment noting it's a state-based simulation (not a true concurrent race) would set expectations.

  2. Nice touch: recreatedSameNameUserCannotAdoptPriorSession now asserts the pre-invalidation resolve, pinning that removal (not the identity check) is the guard. Good regression intent.

Minor / nits

  1. 401 -> 404 for a stale session on a write handler is a client-visible status change; it's documented in the changelog section, and 404 is defensible (also avoids leaking session existence to a non-owner), so just flagging for release-notes visibility.

  2. removeSession(exchange, user) does two manager lookups (getSessionById then removeSession(id)), each taking the RW lock. Negligible, but a single ownership-checked remove method on the manager would be marginally cleaner and race-free.

Verified as fine

  • Catch ordering in AbstractServerHttpHandler is correct (HttpSessionException before its TransactionException supertype).
  • import io.undertow.util.StatusCodes correctly removed (no remaining use).
  • Lock ordering: execute() takes session-lock then manager read-lock (isSessionRegistered); removeSessionsForUser/dropUser take manager write-lock, release it, then take the session lock via cancel() - no lock-order inversion, no deadlock.
  • Idempotent retry path (requiresTransaction()==false -> null session -> isTransactionActive() guard -> 204) is correct for both /commit and /rollback.

Nice work - the concerns above are refinements, not blockers.

Reviewed by Claude

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.90909% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.73%. Comparing base (1b2a2d3) to head (247a9fb).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...a/com/arcadedb/server/http/HttpSessionManager.java 81.81% 1 Missing and 3 partials ⚠️
...b/server/http/handler/DatabaseAbstractHandler.java 88.88% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5175      +/-   ##
============================================
+ Coverage     66.00%   66.73%   +0.73%     
============================================
  Files          1693     1694       +1     
  Lines        136917   137115     +198     
  Branches      29270    29317      +47     
============================================
+ Hits          90368    91502    +1134     
+ Misses        34306    33201    -1105     
- Partials      12243    12412     +169     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

robfrank added 5 commits July 9, 2026 13:25
…e-validation, ownership enforced

Server-audit fix for three HTTP transaction-session lifecycle defects:

- Defect 1: PostCommitHandler/PostRollbackHandler now unregister the
  server-side session after commit/rollback and are idempotent (guard with
  isTransactionActive, retry returns 204). An unresolved session id on a
  write-capable handler is rejected with an explicit HttpSessionException
  (HTTP 404) instead of running the command in a silent implicit
  auto-committing transaction; read-only and /begin,/commit,/rollback
  endpoints degrade to a session-less request so read-after-commit and
  idempotent retries keep working.
- Defect 2: HttpSession.execute re-validates the session is still
  registered under the session lock before running its callback, so the
  idle-timeout sweep can no longer roll back and remove a session between
  the manager lookup and lock acquisition and leave the request operating
  on a rolled-back (leaked) transaction.
- Defect 3: HttpSessionManager.getSessionById enforces ownership before
  attach; removeSessionsForUser plus ServerSecurity dropUser/setUserPassword/
  updateUser invalidate a principal's live sessions so a dropped-and-recreated
  same-name principal cannot adopt the prior principal's open session.

Tests: Issue5026HttpSessionLifecycleTest (unit) and
Issue5026TransactionSessionLifecycleIT (HTTP end-to-end).
Guard against a null session.user when enforcing ownership in
HttpSessionManager.getSessionById, matching the defensive null-handling
already used in removeSessionsForUser. An ownerless session is never
resolvable by an authenticated principal. Addresses review feedback.
…n tests

Address review feedback:
- High: removeSession(exchange) removed a session by raw header id with no
  ownership check, so a request carrying another principal's session id could
  evict that session from the manager without rolling it back, orphaning its
  transaction. removeSession now takes the authenticated user and only removes
  a session that user owns (via getSessionById); a foreign/stale id is a benign
  no-op. Regression test commitWithAnotherUsersSessionDoesNotOrphanIt.
- Tighten executeRefusesToRunOnAnUnregisteredSession to assert HttpSessionException.
…radation test

Address second-round review feedback:
- updateUser no longer force-rolls-back the principal's live transactions: a
  metadata update is benign and per-request authorization is still re-checked
  on every command. Only dropUser and setUserPassword invalidate live sessions
  (matching the issue's Defect 3 scope).
- Document the ownership invariant in getSessionById: recreated-same-name
  protection relies on removeSessionsForUser being invoked from every user
  removal/mutation path.
- Add HTTP test staleSessionOnReadDegradesToSessionlessRead pinning the
  read-after-commit degradation contract at the HTTP layer.
- Fix import ordering in ServerSecurity.
…havior; strengthen test

Address third-round review feedback:
- dropUser now performs the users-map mutation under the security monitor but
  runs session invalidation (which can block on an in-flight command via
  cancel()) OUTSIDE the monitor, so a long-running victim command no longer
  stalls other admin ops serialized on it. setUserPassword was already
  unsynchronized.
- Strengthen recreatedSameNameUserCannotAdoptPriorSession with a companion
  assertion proving a same-name principal WOULD resolve the session until
  invalidation removes it - i.e. removal, not the identity check, is the guard.
- Document the stale-session behavior contract (read degradation vs write 404,
  drop/password invalidation) in the tracking doc for the changelog.
@robfrank
robfrank force-pushed the fix/5026-http-tx-session-lifecycle branch from daeda6b to 247a9fb Compare July 9, 2026 11:27
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review: HTTP transaction session lifecycle (#5026)

Reviewed the full diff plus the surrounding code (DatabaseAbstractHandler, HttpSession, HttpSessionManager, ServerSecurity, and the commit/rollback/begin/query handlers). This is high-quality work: the three defects are real, the fixes are correct, the lock-ordering reasoning is sound, and the inline documentation is excellent. A few things worth addressing.

Strengths

  • Lock ordering is careful and correct. removeSessionsForUser and close() both unregister under the manager write lock and then cancel() outside it, avoiding holding the manager lock across the unbounded per-session wait. The new execute() re-validation (session.lock -> manager read lock) doesn't invert against the sweep, which only ever takes session.lock via non-blocking tryLock() while holding the manager lock. No new deadlock.
  • Exception mapping is correct. HttpSessionException extends TransactionException and the new catch arm is correctly placed before the TransactionException arm in AbstractServerHttpHandler.
  • The read/write handler split is right. requiresTransaction() is true only for the default write handler and false for /query, /begin, /commit, /rollback, so writes reject a stale session (404) while reads/tx-endpoints degrade session-less. The isTransactionActive() guards make retried commit/rollback genuinely idempotent (204).
  • Strong test coverage across both the unit level (ownership, execute re-validation, removeSessionsForUser) and the HTTP/IT level (stale-write 404, idempotent retries, read-degrades-to-200, non-owner commit doesn't orphan, drop-user invalidation).

Findings

1. (Medium - security consistency) Password change via PUT /user does not invalidate live sessions.
setUserPassword() now calls invalidateHttpSessions() with the rationale "a password change re-authenticates the principal: drop its live HTTP transaction sessions." But PutUserHandler changes the password through updateUser() (updatedConfig.put("password", ...) at PutUserHandler.java:63), and updateUser() deliberately does not invalidate sessions. So the same logical operation (rotating a user's password) tears down live sessions via one endpoint (setUserPassword) but not the other (PUT /user). If an admin rotates a password to lock out a compromised principal via the REST endpoint, that principal's open transaction session survives. Consider invalidating in updateUser() when the config actually changes the password (compare old vs new encoded password), while still preserving the "pure metadata/grant update does not tear down in-flight work" behavior you documented.

2. (Low) Narrow race in dropUser between monitor release and invalidation.
dropUser now releases the ServerSecurity monitor before calling invalidateHttpSessions(userName) (good, for the reason documented). But in that window another thread could createUser the same name and /begin a session; the subsequent removeSessionsForUser(name) would then kill the freshly-created same-name user's session. Very unlikely and arguably acceptable, but worth a comment acknowledging it, since same-name recreation is precisely the scenario this fix targets.

3. (Low - test) Defect 2 (sweep-vs-request race) is only simulated, not exercised concurrently.
executeRefusesToRunOnAnUnregisteredSession reproduces the condition by manually removeSession()-ing before execute(), which validates the re-check but not the actual timing race between the sweep's tryLock/it.remove() and execute() acquiring the lock. Since HttpSessionTimeoutRaceTest already exists for the adjacent race, a short concurrent stress variant here would harden confidence that the re-validation closes the real window.

Minor / nits

  • In the owner commit/rollback path the session is already resolved in setTransactionInThreadLocal, yet removeSession(exchange, user) re-resolves it with a second getSessionById (extra read-lock round trip). Combined with the isSessionRegistered check in execute(), a single session command now takes 3 manager read/write-lock round trips. All are cheap (RWLockContext), so this is negligible - noting only for completeness.
  • HttpSessionException's message includes the raw session id; that's a server-generated AS-<uuid>, so no injection concern, just flagging that it's echoed in the 404 body.

Nice work overall - the fixes are correct and the reasoning in the comments will age well. Finding #1 is the one I'd resolve before merge.

@robfrank
robfrank merged commit a6c10d1 into main Jul 9, 2026
22 of 26 checks passed
@codacy-production

codacy-production Bot commented Jul 9, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 20 complexity

Metric Results
Complexity 20

View in Codacy

🟢 Coverage 98.18% diff coverage · +28.04% coverage variation

Metric Results
Coverage variation +28.04% coverage variation
Diff coverage 98.18% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (23936cc) 137066 55403 40.42%
Head commit (247a9fb) 168953 (+31887) 115662 (+60259) 68.46% (+28.04%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#5175) 55 54 98.18%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

lekmaneb pushed a commit to lekmaneb/arcadedb that referenced this pull request Jul 9, 2026
Bumps `swagger.version` from 2.2.50 to 2.2.51.
Updates `io.swagger.core.v3:swagger-core` from 2.2.50 to 2.2.51
Release notes

*Sourced from [io.swagger.core.v3:swagger-core's releases](https://github.com/swagger-api/swagger-core/releases).*

> Swagger-core 2.2.51 released!
> -----------------------------
>
> * chore(deps-dev): bump logback-version from 1.5.25 to 1.5.32 ([ArcadeData#5149](https://redirect.github.com/swagger-api/swagger-core/issues/5149))
> * chore(deps): bump slf4j-version from 2.0.9 to 2.0.17 ([ArcadeData#5137](https://redirect.github.com/swagger-api/swagger-core/issues/5137))


Commits

* [`6e2eabd`](swagger-api/swagger-core@6e2eabd) prepare release 2.2.51 ([ArcadeData#5194](https://redirect.github.com/swagger-api/swagger-core/issues/5194))
* [`6e74355`](swagger-api/swagger-core@6e74355) chore(deps-dev): bump logback-version from 1.5.25 to 1.5.32 ([ArcadeData#5149](https://redirect.github.com/swagger-api/swagger-core/issues/5149))
* [`1bc8c05`](swagger-api/swagger-core@1bc8c05) chore(deps): bump slf4j-version from 2.0.9 to 2.0.17 ([ArcadeData#5137](https://redirect.github.com/swagger-api/swagger-core/issues/5137))
* [`7d89d07`](swagger-api/swagger-core@7d89d07) bump snapshot 2.2.51-SNAPSHOT ([ArcadeData#5175](https://redirect.github.com/swagger-api/swagger-core/issues/5175))
* See full diff in [compare view](swagger-api/swagger-core@v2.2.50...v2.2.51)
  
Updates `io.swagger.core.v3:swagger-annotations` from 2.2.50 to 2.2.51
Updates `io.swagger.core.v3:swagger-models` from 2.2.50 to 2.2.51
lekmaneb pushed a commit to lekmaneb/arcadedb that referenced this pull request Jul 9, 2026
…0.0 in /e2e-python [skip ci]

Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version.
Changelog

*Sourced from [setuptools's changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst).*

> v83.0.0
> =======
>
> Features
> --------
>
> * Require Python 3.10 or later.
>
> Bugfixes
> --------
>
> * `MANIFEST.in` matching (via `FileList`) is now insensitive to Unicode
>   normalization form. A pattern authored in one form (e.g. NFC, as typically
>   saved by editors) now matches a file whose name is stored on disk in another
>   (e.g. NFD, as produced by macOS APFS/HFS+). Previously an `exclude`,
>   `global-exclude`, `recursive-exclude`, or `prune` rule could silently
>   fail to drop a non-ASCII-named file from the source distribution, publishing
>   it despite the exclusion -- see GHSA-h35f-9h28-mq5c.
>
> Deprecations and Removals
> -------------------------
>
> * `pypa/distutils#334`
>
> v82.0.1
> =======
>
> Bugfixes
> --------
>
> * Fix the loading of `launcher manifest.xml` file. ([ArcadeData#5047](https://redirect.github.com/pypa/setuptools/issues/5047))
> * Replaced deprecated `json.__version__` with fixture in tests. ([ArcadeData#5186](https://redirect.github.com/pypa/setuptools/issues/5186))
>
> Improved Documentation
> ----------------------
>
> * Add advice about how to improve predictability when installing sdists. ([ArcadeData#5168](https://redirect.github.com/pypa/setuptools/issues/5168))
>
> Misc
> ----
>
> * [ArcadeData#4941](https://redirect.github.com/pypa/setuptools/issues/4941), [ArcadeData#5157](https://redirect.github.com/pypa/setuptools/issues/5157), [ArcadeData#5169](https://redirect.github.com/pypa/setuptools/issues/5169), [ArcadeData#5175](https://redirect.github.com/pypa/setuptools/issues/5175)
>
> v82.0.0

... (truncated)


Commits

* [`6519f72`](pypa/setuptools@6519f72) Bump version: 82.0.1 → 83.0.0
* [`d1151b1`](pypa/setuptools@d1151b1) Merge pull request [ArcadeData#5250](https://redirect.github.com/pypa/setuptools/issues/5250) from pypa/feature/distutils-d7633fbed
* [`a2df31e`](pypa/setuptools@a2df31e) Capture removal of dry\_run parameter in changelog.
* [`00144dc`](pypa/setuptools@00144dc) Moved newsfragment to the release where it occurred.
* [`a4a5a2b`](pypa/setuptools@a4a5a2b) Add news fragment.
* [`77470c2`](pypa/setuptools@77470c2) Merge <https://github.com/pypa/distutils> into feature/distutils-d7633fbed
* [`3c43897`](pypa/setuptools@3c43897) Merge pull request [ArcadeData#5247](https://redirect.github.com/pypa/setuptools/issues/5247) from pypa/copilot/fix-pypy-version-issue
* [`bb6ea66`](pypa/setuptools@bb6ea66) Bump PyPy from 3.10 to 3.11 in CI workflow
* [`a2bc3ac`](pypa/setuptools@a2bc3ac) Fix broken intersphinx reference to build's installation docs
* [`2d6a739`](pypa/setuptools@2d6a739) Use stacked parametrize decorators instead of itertools.product
* Additional commits viewable in [compare view](pypa/setuptools@v82.0.1...v83.0.0)
  
Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
  
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot show  ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[server audit] HTTP transaction sessions: not removed on commit/rollback, idle-sweep race leaks a transaction, ownership checked by name

1 participant