Why must an Object Pool reset a borrowed object's state before or after each lease, and what goes wrong when the reset is incomplete?
answer
- Pooled object outlives the lease → shared state
- Open transaction, session vars, unread rows
- Thread-locals = cross-user security bug
- Zero buffers, don't just reset indices
- Can't prove it's clean? destroy, don't recycle
basics
~20 sBecause the same object is reused by different callers. If one caller's leftover state — an open transaction, a half-filled buffer, a per-user setting — survives, the next caller silently inherits it. That causes wrong results and can leak one user's data into another user's request.
solid answer
~60 sA pooled object outlives the request that borrowed it, so any mutable state it carries becomes cross-request shared state unless it is scrubbed. Typical leftovers: an uncommitted transaction, a changed isolation level or session variable, a temporary table, a buffer's read/write positions, an unread response still in the socket, thread-locals on a pooled thread, an authentication or tenant context. Incomplete reset produces state contamination: at best flaky, hard-to-reproduce bugs; at worst a security incident, because thread-locals holding a user principal or tenant id will be observed by whoever borrows that thread next. Reset on return is the usual choice (fail fast, keep the idle set clean); reset on borrow protects against a crashing borrower that never returned properly; some pools do both. If reset can fail or cannot be proven complete — a connection left in an unknown protocol state, a thread with unknown thread-locals — the safe move is to destroy the instance rather than recycle it. And make reset the pool's job, not a convention callers are trusted to follow.
code
pseudocode · 11 linesfunction release(obj):
try:
obj.rollbackIfInTransaction()
obj.resetSessionSettings()
obj.clearThreadLocals()
obj.buffer.zeroFill() // not just position = 0
catch (any):
destroy(obj) // unknown state -> never recycle
return
handle.invalidate() // late use of the old handle throws
idleSet.add(obj)go deeper
Say the object is reused, so leftover state affects the next user; give one concrete example like an open transaction or a partly-filled buffer.
Enumerate categories (transaction, session settings, unread results, thread-locals, buffer indices) and state that reset belongs in the pool's release path.
Compare reset-on-borrow vs on-return, argue for destroy-when-unsure, and describe handle invalidation so a stale reference can't touch a re-leased object.
Frame it as a state-ownership boundary and a security invariant: prefer designs where per-request context can't be attached to pooled objects at all (explicit context passing over ambient thread-locals), and require contamination tests in CI.
## The root cause A pooled object's lifetime is **longer than the lease that uses it**. Everything mutable it carries is therefore a channel between two unrelated callers. Object pooling silently converts "per-request state" into "shared state" unless the pool actively scrubs it. ## What actually leaks between leases ### Database connections - **Open transaction** — the borrower threw before commit/rollback. The next borrower joins an in-progress transaction, sees uncommitted rows, and its own "commit" commits the previous caller's partial work. Locks held by that transaction also block other sessions. - **Autocommit / isolation level / session variables** — `SET TRANSACTION ISOLATION LEVEL SERIALIZABLE`, `SET search_path`, `SET time_zone`, `SET ROLE`. All are session-scoped and survive. - **Temporary tables, prepared statements, cursors, advisory locks, `SET ROLE`/`SESSION AUTHORIZATION`** — persist for the session's life. - **Unread result rows** — abandoning a partly-consumed result set leaves bytes in the socket; the next query reads the previous query's tail and the protocol desynchronizes, producing bizarre type errors. ### Threads (a thread pool is an object pool over threads) - **Thread-locals** — the classic security bug. Security context, tenant id, request id, MDC logging context, transaction context. If task A sets a `currentUser` thread-local and does not clear it, task B on that thread sees A's user. This is a real, repeatedly-shipped vulnerability class, and it is invisible in single-request testing. - **Interrupt flag** — a task interrupted late leaves the flag set; the next task on that thread fails spuriously on its first blocking call. - **Thread name, priority, context class loader** — changed for debugging, never restored; a changed context class loader can also pin a redeployed application's classes and cause a leak. ### Buffers and byte arrays - **Position/limit/mark indices** — not reset means the next user writes at the wrong offset. - **Residual bytes** — the biggest hazard. A buffer handed out without zeroing may be *read* by the new owner beyond what it wrote, exposing the previous request's payload. Anything holding credentials or other users' data must be zeroed, not merely index-reset. - **Retained references** — object arrays that keep pointing at old elements both leak memory and expose data. ### Protocol/session objects - Negotiated compression or encryption state, sequence numbers, pending server pushes, subscription registrations. ## Reset on borrow, on return, or both? | Strategy | Strength | Weakness | |---|---|---| | **On return** | Idle set is always clean; problems surface attributed to the caller that caused them; idle objects hold no stale data | A borrower that dies without returning never resets — but then it usually never returns at all | | **On borrow** | Defends against anything that skipped return, and against pool-internal bugs | Adds latency to the hot path; stale data sits in idle objects meanwhile | | **Both** | Safest | Doubles the cost | Most production connection pools reset on return (rollback if not autocommit, reset session settings, clear warnings) and additionally **validate** on borrow. Buffer pools that carry sensitive data often clear on return so idle memory never holds secrets. Thread pools should clear thread-locals in a `finally` around each task. ## When reset is impossible: destroy instead Recycling should be conditional. Discard rather than reuse when: - The borrower reported an error whose scope is unclear (a protocol/IO exception on a connection). - Reset itself failed (rollback threw). - The object exceeded a max lifetime, or validation says it is dead. - The state space is not fully enumerable — e.g. arbitrary code could have set unknown session variables or unknown thread-locals. The cost of throwing away one expensive object is bounded and known; the cost of leaking one user's context into another's request is not. ## Make it the pool's job A rule like "callers must always roll back before returning the connection" is a rule someone will break in a rare error path. Enforce it structurally: the pool's `release` performs the reset; the borrowed handle is a **wrapper/proxy** that the pool can invalidate on return so late use of a stale handle throws instead of touching a now-someone-else's object; a scoped API (`pool.withResource { ... }`) removes the possibility of a caller forgetting entirely. ## Testing for contamination Contamination is invisible with a pool of size 1 and one test at a time. Techniques that expose it: run tests with pool size 1 so consecutive tests *must* share the instance; assert at return time that state is pristine (transaction closed, thread-local map empty) and fail the test if not; poison the object between leases in test builds (fill buffers with a marker byte, set a sentinel thread-local) so any reader of stale state blows up loudly.
- Why are thread-locals on a pooled thread considered a security problem and not just a correctness problem?Because they commonly hold identity: the authenticated principal, tenant id, or permissions. If task A leaves them set, task B running later on the same thread inherits A's identity and may read or write data as that user. It manifests only under reuse, so it survives normal testing and shows up in production as cross-tenant data exposure.
- Why zero a returned byte buffer instead of just resetting its position and limit?Resetting indices only changes the bookkeeping; the previous request's bytes are still in memory. A new owner that reads beyond what it wrote — through an off-by-one, an over-long length field, or a partial fill — sees the old payload. Zeroing makes that leak impossible, at the cost of a memset.
A rental car: between renters the company doesn't just hand over the keys — it empties the glovebox, resets the seats and the trip meter, and clears the navigation history. Skip that and the next driver finds the last one's garage door opener and home address.
saying these in an interview costs you the question
- "The next caller will overwrite it anyway" — only true if it writes everything before reading anything
- Relying on callers to clean up by convention rather than the pool enforcing it in release()
- Resetting a buffer's position/limit but leaving the bytes in place
- Recycling a connection after an unknown protocol error instead of discarding it
- Forgetting that a thread pool is an object pool, so thread-locals need the same discipline
- Believing an uncommitted transaction is harmless because "nothing was committed" — it still holds locks and pollutes the next lease