This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Poolex is an Elixir library for managing pools of workers, inspired by poolboy. It provides a GenServer-based worker pool with configurable overflow workers, delayed shutdown, and pluggable implementations for managing worker and caller collections.
mix deps.get- Install dependenciesmix check- Run all code analysis & testing tools (includes tests, dialyzer, credo, format check)mix test- Run tests onlymix format- Format codemix dialyzer- Run Dialyzer type checkingmix credo- Run code analysis
mix test- Run all testsmix test --cover- Run tests with coveragemix test test/poolex_test.exs- Run specific test filemix test --only focus- Run only focused tests (tagged with @tag :focus)
mix docs- Generate documentationmix hex.docs open- Open generated docs in browser
The main pool GenServer (Poolex) manages several internal components:
- State Management:
Poolex.Private.State- Core state structure containing pool configuration and runtime state - Worker Collections: Pluggable implementations for managing different types of workers:
idle_workers- Available workers ready for checkoutbusy_workers- Workers currently in useidle_overflowed_workers- Overflow workers with delayed shutdown
- Caller Management:
waiting_callers- Queue of processes waiting for workers - Monitoring:
Poolex.Private.Monitoring- Tracks worker and caller processes - Metrics:
Poolex.Private.Metrics- Telemetry integration for pool metrics
- Workers are started via
DynamicSupervisorunderPoolex.Private.Supervisor - Idle workers are stored in configurable collections (List or ErlangQueue implementations)
- When requested, workers move from idle → busy state
- After use, workers either return to idle or are shut down (if overflow)
- Failed workers are automatically restarted with configurable retry intervals
The library uses behaviour-based implementations for:
- Workers:
Poolex.Workers.Behaviour- How to store/retrieve worker PIDs - Callers:
Poolex.Callers.Behaviour- How to queue waiting callers
Built-in implementations:
Poolex.Workers.Impl.List- Simple list-based storagePoolex.Workers.Impl.ErlangQueue- Erlang queue-based storagePoolex.Callers.Impl.ErlangQueue- Erlang queue for caller management
- Overflow Workers: Temporary workers beyond the base pool size
- Worker Shutdown Delay: Configurable delay before stopping overflow workers
- Failed Worker Retry: Automatic retry of failed worker initialization
- Pool Metrics: Telemetry integration for monitoring pool state
- Process Monitoring: Automatic cleanup when workers or callers crash
Key pool configuration (see Poolex.poolex_option() type):
worker_module- Module implementing the worker (required)workers_count- Base number of workers (required)max_overflow- Additional workers allowed beyond base countworker_shutdown_delay- Delay before stopping overflow workerspool_id- Identifier for the pool (defaults to worker_module)worker_args- Arguments passed to worker start function*_imploptions - Custom implementations for worker/caller management
- Test files are in
test/directory - Test support modules in
test/support/ - Start pools with
PoolHelpers.start_pool/1(test/support/pool_helpers.ex, wrapsstart_supervised); the defaultpool_idis the worker module - Common test pattern: start pool → call
Poolex.run/3→ verify behavior - Don't wait for the pool with a fixed
Process.sleep/1— that is what made the suite flaky on loaded CI runners. Wrap the assertion inPoolHelpers.eventually/2(retries until it passes or times out), orassert_receivean explicit signal sent after the action under test.launch_long_task(s)already returns only once every caller holds a worker or is queued - Make sure an
eventuallyassertion can't pass on the initial state (e.g. await the tasks that change the state first) - Casts sent by the test process are handled before its next
:sys.get_state/1orGenServer.callto the pool, so no wait is needed in between - Keep fixed sleeps only for checking that something does not happen, or where elapsed time is the point (shutdown delays); leave margins of hundreds of milliseconds
- Timing flakes rarely show up on an idle dev machine. To shake them out locally, run the suite under CPU contention, e.g. start one
yes > /dev/nullper core and loopERL_FLAGS="+S 2:2" mix test --seed $RANDOM(under such load sleeps overshoot by up to ~400 ms) - To reproduce races between messages in the pool's mailbox deterministically,
:sys.suspend(pool)→ trigger the senders → wait for them (e.g.assert_receive {:DOWN, ...}) →:sys.resume(pool). This emulates a loaded pool. Example:describe "caller dies abnormally right after release"intest/poolex_manual_acquisition_test.exs - Registry-based naming for test isolation
- Uses git-flow branch naming:
mainfor releases,developfor development; PRs targetdevelop git flowtooling is not initialized in the repo — create feature branches with plaingit checkout -b feature/<name>fromdevelop- Always rebase, never merge when integrating upstream changes
- Run
mix checkbefore committing. After a failed run,mix checkre-runs only the failed tools (retry mode); usemix check --no-retryfor a full run
- Elixir >= 1.17, Erlang/OTP >= 25
- Use
asdffor version management (see.tool-versions)
remove_idle_workers!/2Design Issue- Current behavior: Removes workers from idle list but doesn't stop them
- Problem: Workers continue running but are inaccessible to pool (potential resource leak)
- Root cause: Can't easily unmonitor by pid (only by reference)
- Workaround: Would need reverse monitor map (
pid → reference) - Better solution: See "Planned Architecture Improvements" in TODO.md
Current Monitoring Approach:
- Uses
Process.monitorwith%{reference() => kind_of_process()}map - Limitation: Can't unmonitor by pid, only by reference
- Makes features like
remove_idle_workers!difficult to implement correctly - Pool sets
trap_exit: trueininit/1and handles{:EXIT, pid, reason}inhandle_info(stops the pool gracefully on exit signals) - Every
acquire/2(and so everyrun/3) spawns a watcher process (start_manual_monitor/3) that reports an abnormal caller death as{:manual_caller_down, worker, monitor_pid}. The report is asynchronous and can arrive after the worker was released and handed to another caller, so the pool acts on it only whilemanual_monitors[worker]still points at that monitor. Any new asynchronous message about a specific worker needs the same kind of ownership check
Proposed Future Improvement (see TODO.md):
- Replace
Process.monitorwithProcess.link+trap_exit(full refactoring) - Would eliminate need for monitors map entirely
- Would make
remove_idle_workers!trivial:Process.unlink(worker) + terminate_child - Significant architectural simplification
- When adding new worker lifecycle features, be aware of monitoring limitations
- Consider the
Process.linkrefactoring (in TODO.md) for major features requiring worker removal - Always run
mix checkbefore committing (includes tests, dialyzer, credo)