container-pool 0.1.0__py3-none-any.whl
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.
- container_pool/__init__.py +51 -0
- container_pool/_backend.py +92 -0
- container_pool/_container.py +87 -0
- container_pool/_exceptions.py +46 -0
- container_pool/_pool.py +220 -0
- container_pool/_retry.py +53 -0
- container_pool/_tracker.py +56 -0
- container_pool/_types.py +36 -0
- container_pool/backends/__init__.py +4 -0
- container_pool/backends/openai.py +158 -0
- container_pool-0.1.0.dist-info/METADATA +212 -0
- container_pool-0.1.0.dist-info/RECORD +14 -0
- container_pool-0.1.0.dist-info/WHEEL +4 -0
- container_pool-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
container-pool: Provider-agnostic async container pool with expiry recovery.
|
|
3
|
+
|
|
4
|
+
Quick start:
|
|
5
|
+
from container_pool import ContainerPool
|
|
6
|
+
from container_pool.backends.openai import OpenAIContainerBackend
|
|
7
|
+
|
|
8
|
+
backend = OpenAIContainerBackend(openai_client)
|
|
9
|
+
pool = ContainerPool(backend, max_pool_size=5, acquire_timeout=30.0, container_name="ci")
|
|
10
|
+
|
|
11
|
+
container = await pool.acquire()
|
|
12
|
+
try:
|
|
13
|
+
uploaded = await container.upload_file("/tmp/data.csv")
|
|
14
|
+
...
|
|
15
|
+
finally:
|
|
16
|
+
await pool.release(container)
|
|
17
|
+
|
|
18
|
+
await pool.shutdown()
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from ._backend import BaseContainerBackend
|
|
22
|
+
from ._container import Container
|
|
23
|
+
from ._exceptions import (
|
|
24
|
+
ContainerCreationError,
|
|
25
|
+
ContainerExpiredError,
|
|
26
|
+
ContainerFileError,
|
|
27
|
+
ContainerPoolError,
|
|
28
|
+
ContainerPoolExhaustedError,
|
|
29
|
+
)
|
|
30
|
+
from ._pool import ContainerPool
|
|
31
|
+
from ._tracker import RequestFileTracker
|
|
32
|
+
from ._types import ContainerInfo, ContainerStatus, UploadedFile
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
# Core
|
|
36
|
+
"ContainerPool",
|
|
37
|
+
"Container",
|
|
38
|
+
"RequestFileTracker",
|
|
39
|
+
# ABC
|
|
40
|
+
"BaseContainerBackend",
|
|
41
|
+
# Types
|
|
42
|
+
"ContainerInfo",
|
|
43
|
+
"ContainerStatus",
|
|
44
|
+
"UploadedFile",
|
|
45
|
+
# Exceptions
|
|
46
|
+
"ContainerPoolError",
|
|
47
|
+
"ContainerPoolExhaustedError",
|
|
48
|
+
"ContainerExpiredError",
|
|
49
|
+
"ContainerCreationError",
|
|
50
|
+
"ContainerFileError",
|
|
51
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from ._types import ContainerInfo, UploadedFile
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseContainerBackend(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Abstract interface every backend must implement.
|
|
11
|
+
|
|
12
|
+
All methods are async. Implementations are responsible for:
|
|
13
|
+
- Translating vendor-specific errors into ContainerExpiredError /
|
|
14
|
+
ContainerCreationError / ContainerFileError.
|
|
15
|
+
- Never leaking vendor SDK types through return values.
|
|
16
|
+
- Best-effort operations (destroy, delete_file) should swallow 404 silently.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# -------------------------------------------------------------------------
|
|
20
|
+
# Lifecycle
|
|
21
|
+
# -------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
async def create_container(self, name: str) -> ContainerInfo:
|
|
25
|
+
"""
|
|
26
|
+
Create and activate a new container.
|
|
27
|
+
Raises ContainerCreationError on failure (after any internal retry).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
async def get_container(self, container_id: str) -> ContainerInfo:
|
|
32
|
+
"""
|
|
33
|
+
Fetch the current status of an existing container.
|
|
34
|
+
Raises ContainerExpiredError if the container is gone or expired.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def destroy_container(self, container_id: str) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Permanently destroy a container.
|
|
41
|
+
Best-effort: should swallow 404 silently.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# -------------------------------------------------------------------------
|
|
45
|
+
# File operations
|
|
46
|
+
# -------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def upload_file(self, container_id: str, local_path: str) -> UploadedFile:
|
|
50
|
+
"""
|
|
51
|
+
Upload a local file into the container.
|
|
52
|
+
Returns an UploadedFile with container_id, file_id, container_path.
|
|
53
|
+
Raises ContainerFileError on failure.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
async def download_file_content(self, container_id: str, file_id: str) -> bytes:
|
|
58
|
+
"""
|
|
59
|
+
Download a file's raw bytes by file_id.
|
|
60
|
+
Raises ContainerFileError on failure.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
async def download_file_to_disk(
|
|
65
|
+
self,
|
|
66
|
+
container_id: str,
|
|
67
|
+
file_id: str,
|
|
68
|
+
local_path: str,
|
|
69
|
+
) -> int:
|
|
70
|
+
"""
|
|
71
|
+
Download a file and write it to local_path.
|
|
72
|
+
Returns the number of bytes written.
|
|
73
|
+
Raises ContainerFileError on failure.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
async def delete_file(self, container_id: str, file_id: str) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Delete a single file from the container.
|
|
80
|
+
Best-effort: should swallow 404 silently.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
async def list_files(
|
|
85
|
+
self,
|
|
86
|
+
container_id: str,
|
|
87
|
+
path_prefix: str = "",
|
|
88
|
+
) -> dict[str, str]:
|
|
89
|
+
"""
|
|
90
|
+
List files inside the container whose path starts with path_prefix.
|
|
91
|
+
Returns {filename: file_id}.
|
|
92
|
+
"""
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from ._backend import BaseContainerBackend
|
|
7
|
+
from ._types import UploadedFile
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Container:
|
|
13
|
+
"""
|
|
14
|
+
A live handle to a single container slot.
|
|
15
|
+
|
|
16
|
+
Returned by ContainerPool.acquire(). Callers use this object to
|
|
17
|
+
upload/download files. They must call pool.release(container) when done.
|
|
18
|
+
|
|
19
|
+
Container does not manage expiry or recreation — that is the pool's job.
|
|
20
|
+
Container does not hold a reference to the pool.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, container_id: str, backend: BaseContainerBackend) -> None:
|
|
24
|
+
self.container_id = container_id
|
|
25
|
+
self._backend = backend
|
|
26
|
+
|
|
27
|
+
# -------------------------------------------------------------------------
|
|
28
|
+
# File operations — thin delegation to backend
|
|
29
|
+
# -------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
async def upload_file(self, local_path: str) -> UploadedFile:
|
|
32
|
+
return await self._backend.upload_file(self.container_id, local_path)
|
|
33
|
+
|
|
34
|
+
async def download_file_content(self, file_id: str) -> bytes:
|
|
35
|
+
return await self._backend.download_file_content(self.container_id, file_id)
|
|
36
|
+
|
|
37
|
+
async def download_file_to_disk(self, file_id: str, local_path: str) -> int:
|
|
38
|
+
return await self._backend.download_file_to_disk(
|
|
39
|
+
self.container_id, file_id, local_path
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async def delete_file(self, file_id: str) -> None:
|
|
43
|
+
await self._backend.delete_file(self.container_id, file_id)
|
|
44
|
+
|
|
45
|
+
async def delete_files(self, file_ids: list[str]) -> None:
|
|
46
|
+
"""Best-effort bulk delete. Logs and continues on individual failures."""
|
|
47
|
+
for fid in file_ids:
|
|
48
|
+
try:
|
|
49
|
+
await self._backend.delete_file(self.container_id, fid)
|
|
50
|
+
except Exception:
|
|
51
|
+
log.warning(
|
|
52
|
+
"Failed to delete file %r from container %r",
|
|
53
|
+
fid,
|
|
54
|
+
self.container_id,
|
|
55
|
+
exc_info=True,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def list_output_files(self, path_prefix: str = "") -> dict[str, str]:
|
|
59
|
+
"""List files in the container, optionally filtered by path prefix."""
|
|
60
|
+
return await self._backend.list_files(self.container_id, path_prefix)
|
|
61
|
+
|
|
62
|
+
async def download_files(
|
|
63
|
+
self,
|
|
64
|
+
file_ids: dict[str, str],
|
|
65
|
+
output_dir: str,
|
|
66
|
+
) -> dict[str, str]:
|
|
67
|
+
"""
|
|
68
|
+
Download multiple files to a local directory.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
file_ids: {filename: file_id}
|
|
72
|
+
output_dir: Local directory to save files into
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
{filename: local_path}
|
|
76
|
+
"""
|
|
77
|
+
results: dict[str, str] = {}
|
|
78
|
+
for filename, file_id in file_ids.items():
|
|
79
|
+
local_path = os.path.join(output_dir, filename)
|
|
80
|
+
await self._backend.download_file_to_disk(
|
|
81
|
+
self.container_id, file_id, local_path
|
|
82
|
+
)
|
|
83
|
+
results[filename] = local_path
|
|
84
|
+
return results
|
|
85
|
+
|
|
86
|
+
def __repr__(self) -> str:
|
|
87
|
+
return f"Container(id={self.container_id!r})"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
class ContainerPoolError(Exception):
|
|
2
|
+
"""Base class for all container-pool errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ContainerPoolExhaustedError(ContainerPoolError):
|
|
6
|
+
"""
|
|
7
|
+
Raised by ContainerPool.acquire() when no container becomes
|
|
8
|
+
available within acquire_timeout seconds.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, timeout: float, pool_size: int) -> None:
|
|
12
|
+
self.timeout = timeout
|
|
13
|
+
self.pool_size = pool_size
|
|
14
|
+
super().__init__(
|
|
15
|
+
f"No container available after {timeout}s (pool_size={pool_size})"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ContainerExpiredError(ContainerPoolError):
|
|
20
|
+
"""
|
|
21
|
+
Raised by a backend when it detects a container has expired
|
|
22
|
+
(404 or status=expired). The pool catches this and recreates.
|
|
23
|
+
Callers should never see this in normal flow.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, container_id: str) -> None:
|
|
27
|
+
self.container_id = container_id
|
|
28
|
+
super().__init__(f"Container {container_id!r} has expired or been deleted")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ContainerCreationError(ContainerPoolError):
|
|
32
|
+
"""
|
|
33
|
+
Raised when all retries to create a new container have failed.
|
|
34
|
+
Wraps the last underlying exception.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, attempts: int, cause: BaseException) -> None:
|
|
38
|
+
self.attempts = attempts
|
|
39
|
+
self.cause = cause
|
|
40
|
+
super().__init__(
|
|
41
|
+
f"Container creation failed after {attempts} attempt(s): {cause}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ContainerFileError(ContainerPoolError):
|
|
46
|
+
"""Raised when a file upload, download, or delete operation fails."""
|
container_pool/_pool.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from ._backend import BaseContainerBackend
|
|
7
|
+
from ._container import Container
|
|
8
|
+
from ._exceptions import (
|
|
9
|
+
ContainerCreationError,
|
|
10
|
+
ContainerExpiredError,
|
|
11
|
+
ContainerPoolExhaustedError,
|
|
12
|
+
)
|
|
13
|
+
from ._retry import retry_with_backoff
|
|
14
|
+
from ._types import ContainerStatus
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ContainerPool:
|
|
20
|
+
"""
|
|
21
|
+
Async FIFO pool of Container objects backed by any BaseContainerBackend.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
pool = ContainerPool(
|
|
25
|
+
backend,
|
|
26
|
+
max_pool_size=5,
|
|
27
|
+
acquire_timeout=30.0,
|
|
28
|
+
container_name="mypool",
|
|
29
|
+
)
|
|
30
|
+
container = await pool.acquire()
|
|
31
|
+
try:
|
|
32
|
+
...
|
|
33
|
+
finally:
|
|
34
|
+
await pool.release(container)
|
|
35
|
+
await pool.shutdown()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
backend: BaseContainerBackend,
|
|
41
|
+
*,
|
|
42
|
+
max_pool_size: int,
|
|
43
|
+
acquire_timeout: float,
|
|
44
|
+
container_name: str,
|
|
45
|
+
creation_max_attempts: int = 3,
|
|
46
|
+
creation_base_delay: float = 1.0,
|
|
47
|
+
) -> None:
|
|
48
|
+
if not (1 <= max_pool_size <= 50):
|
|
49
|
+
raise ValueError(f"max_pool_size must be 1-50, got {max_pool_size}")
|
|
50
|
+
if acquire_timeout <= 0:
|
|
51
|
+
raise ValueError("acquire_timeout must be positive")
|
|
52
|
+
|
|
53
|
+
self._backend = backend
|
|
54
|
+
self.max_pool_size = max_pool_size
|
|
55
|
+
self.acquire_timeout = acquire_timeout
|
|
56
|
+
self.container_name = container_name
|
|
57
|
+
self._creation_max_attempts = creation_max_attempts
|
|
58
|
+
self._creation_base_delay = creation_base_delay
|
|
59
|
+
|
|
60
|
+
self._queue: asyncio.Queue[Container] = asyncio.Queue()
|
|
61
|
+
# Total containers ever created = items in queue + items checked out.
|
|
62
|
+
# Only decremented on failed creation (rollback) — not on expiry/replace.
|
|
63
|
+
self._total: int = 0
|
|
64
|
+
self._lock = asyncio.Lock() # guards the growth decision (_total += 1)
|
|
65
|
+
self._closed: bool = False
|
|
66
|
+
|
|
67
|
+
# -------------------------------------------------------------------------
|
|
68
|
+
# Public API
|
|
69
|
+
# -------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
async def acquire(self) -> Container:
|
|
72
|
+
"""
|
|
73
|
+
Return a validated, live Container from the pool.
|
|
74
|
+
|
|
75
|
+
Flow:
|
|
76
|
+
1. Queue has an item (non-blocking)? → validate_or_recreate, return.
|
|
77
|
+
2. _total < max_pool_size? → create new container, return.
|
|
78
|
+
3. Otherwise: wait up to acquire_timeout for a release().
|
|
79
|
+
Raises ContainerPoolExhaustedError on timeout.
|
|
80
|
+
"""
|
|
81
|
+
if self._closed:
|
|
82
|
+
raise RuntimeError("ContainerPool is shut down")
|
|
83
|
+
|
|
84
|
+
# Fast path: something already in the queue
|
|
85
|
+
try:
|
|
86
|
+
container = self._queue.get_nowait()
|
|
87
|
+
return await self._validate_or_recreate(container)
|
|
88
|
+
except asyncio.QueueEmpty:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
# Growth path: can we create a new one?
|
|
92
|
+
async with self._lock:
|
|
93
|
+
if self._total < self.max_pool_size:
|
|
94
|
+
self._total += 1
|
|
95
|
+
should_create = True
|
|
96
|
+
else:
|
|
97
|
+
should_create = False
|
|
98
|
+
|
|
99
|
+
if should_create:
|
|
100
|
+
try:
|
|
101
|
+
return await self._create_container_with_retry()
|
|
102
|
+
except Exception:
|
|
103
|
+
async with self._lock:
|
|
104
|
+
self._total -= 1 # roll back — creation failed
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
# Blocking path: wait for someone to release
|
|
108
|
+
try:
|
|
109
|
+
container = await asyncio.wait_for(
|
|
110
|
+
self._queue.get(),
|
|
111
|
+
timeout=self.acquire_timeout,
|
|
112
|
+
)
|
|
113
|
+
except asyncio.TimeoutError:
|
|
114
|
+
raise ContainerPoolExhaustedError(
|
|
115
|
+
timeout=self.acquire_timeout,
|
|
116
|
+
pool_size=self.max_pool_size,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return await self._validate_or_recreate(container)
|
|
120
|
+
|
|
121
|
+
async def release(self, container: Container) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Return a container to the pool.
|
|
124
|
+
|
|
125
|
+
No re-validation at release time — validation happens at the next
|
|
126
|
+
acquire(). This keeps release() fast (safe to call in finally blocks).
|
|
127
|
+
|
|
128
|
+
If the pool is already shut down, destroys the container instead
|
|
129
|
+
of queuing it.
|
|
130
|
+
"""
|
|
131
|
+
if self._closed:
|
|
132
|
+
await self._safe_destroy(container)
|
|
133
|
+
return
|
|
134
|
+
await self._queue.put(container)
|
|
135
|
+
|
|
136
|
+
async def shutdown(self) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Mark pool as closed and destroy all containers currently in the queue.
|
|
139
|
+
|
|
140
|
+
Containers that are checked out at shutdown time will be destroyed
|
|
141
|
+
when release() is called on them. This method is idempotent.
|
|
142
|
+
"""
|
|
143
|
+
if self._closed:
|
|
144
|
+
return
|
|
145
|
+
self._closed = True
|
|
146
|
+
log.info("ContainerPool shutting down. Draining queue...")
|
|
147
|
+
|
|
148
|
+
destroyed = 0
|
|
149
|
+
while True:
|
|
150
|
+
try:
|
|
151
|
+
container = self._queue.get_nowait()
|
|
152
|
+
await self._safe_destroy(container)
|
|
153
|
+
destroyed += 1
|
|
154
|
+
except asyncio.QueueEmpty:
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
log.info("ContainerPool shutdown complete. Destroyed %d container(s).", destroyed)
|
|
158
|
+
|
|
159
|
+
# -------------------------------------------------------------------------
|
|
160
|
+
# Internal helpers
|
|
161
|
+
# -------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
async def _create_container_with_retry(self) -> Container:
|
|
164
|
+
info = await retry_with_backoff(
|
|
165
|
+
lambda: self._backend.create_container(self.container_name),
|
|
166
|
+
max_attempts=self._creation_max_attempts,
|
|
167
|
+
base_delay=self._creation_base_delay,
|
|
168
|
+
retryable=ContainerCreationError,
|
|
169
|
+
)
|
|
170
|
+
log.debug("Created container %r", info.container_id)
|
|
171
|
+
return Container(container_id=info.container_id, backend=self._backend)
|
|
172
|
+
|
|
173
|
+
async def _validate_or_recreate(self, container: Container) -> Container:
|
|
174
|
+
"""
|
|
175
|
+
Confirm the container is alive. If expired or gone, replace it
|
|
176
|
+
with a freshly created one (total pool count stays the same: 1-for-1).
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
info = await self._backend.get_container(container.container_id)
|
|
180
|
+
if info.status == ContainerStatus.ACTIVE:
|
|
181
|
+
return container
|
|
182
|
+
log.info(
|
|
183
|
+
"Container %r has status=%r, recreating.",
|
|
184
|
+
container.container_id,
|
|
185
|
+
info.status,
|
|
186
|
+
)
|
|
187
|
+
except ContainerExpiredError:
|
|
188
|
+
log.info(
|
|
189
|
+
"Container %r returned expired/404, recreating.",
|
|
190
|
+
container.container_id,
|
|
191
|
+
)
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
log.warning(
|
|
194
|
+
"Unexpected error validating container %r (%s), recreating.",
|
|
195
|
+
container.container_id,
|
|
196
|
+
exc,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
await self._safe_destroy(container)
|
|
200
|
+
info = await retry_with_backoff(
|
|
201
|
+
lambda: self._backend.create_container(self.container_name),
|
|
202
|
+
max_attempts=self._creation_max_attempts,
|
|
203
|
+
base_delay=self._creation_base_delay,
|
|
204
|
+
retryable=ContainerCreationError,
|
|
205
|
+
)
|
|
206
|
+
log.debug(
|
|
207
|
+
"Recreated: %r -> %r", container.container_id, info.container_id
|
|
208
|
+
)
|
|
209
|
+
return Container(container_id=info.container_id, backend=self._backend)
|
|
210
|
+
|
|
211
|
+
async def _safe_destroy(self, container: Container) -> None:
|
|
212
|
+
"""Destroy a container, swallowing all errors."""
|
|
213
|
+
try:
|
|
214
|
+
await self._backend.destroy_container(container.container_id)
|
|
215
|
+
except Exception:
|
|
216
|
+
log.warning(
|
|
217
|
+
"Failed to destroy container %r during cleanup",
|
|
218
|
+
container.container_id,
|
|
219
|
+
exc_info=True,
|
|
220
|
+
)
|
container_pool/_retry.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import random
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def retry_with_backoff(
|
|
15
|
+
fn: Callable[[], Awaitable[T]],
|
|
16
|
+
*,
|
|
17
|
+
max_attempts: int = 3,
|
|
18
|
+
base_delay: float = 1.0,
|
|
19
|
+
max_delay: float = 30.0,
|
|
20
|
+
jitter: bool = True,
|
|
21
|
+
retryable: type[BaseException] | tuple[type[BaseException], ...] = Exception,
|
|
22
|
+
) -> T:
|
|
23
|
+
"""
|
|
24
|
+
Call fn() up to max_attempts times with exponential backoff.
|
|
25
|
+
|
|
26
|
+
Delay schedule (before jitter): 1s, 2s, 4s, 8s... capped at max_delay.
|
|
27
|
+
Jitter is ±20% of the computed delay to avoid thundering herd.
|
|
28
|
+
|
|
29
|
+
Only retries on exceptions matching `retryable`.
|
|
30
|
+
Raises the last exception after all attempts are exhausted.
|
|
31
|
+
"""
|
|
32
|
+
last_exc: BaseException | None = None
|
|
33
|
+
|
|
34
|
+
for attempt in range(1, max_attempts + 1):
|
|
35
|
+
try:
|
|
36
|
+
return await fn()
|
|
37
|
+
except retryable as exc:
|
|
38
|
+
last_exc = exc
|
|
39
|
+
if attempt == max_attempts:
|
|
40
|
+
break
|
|
41
|
+
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
|
42
|
+
if jitter:
|
|
43
|
+
delay *= random.uniform(0.8, 1.2)
|
|
44
|
+
log.warning(
|
|
45
|
+
"Attempt %d/%d failed (%s). Retrying in %.2fs.",
|
|
46
|
+
attempt,
|
|
47
|
+
max_attempts,
|
|
48
|
+
exc,
|
|
49
|
+
delay,
|
|
50
|
+
)
|
|
51
|
+
await asyncio.sleep(delay)
|
|
52
|
+
|
|
53
|
+
raise last_exc # type: ignore[misc]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from ._container import Container
|
|
6
|
+
from ._types import UploadedFile
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RequestFileTracker:
|
|
12
|
+
"""
|
|
13
|
+
Wraps a Container for the duration of a single request.
|
|
14
|
+
|
|
15
|
+
Tracks every file_id returned by upload_file(). Call cleanup() once the
|
|
16
|
+
request is complete to delete all tracked files. Keeps containers clean
|
|
17
|
+
between users without the caller needing to track individual file ids.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
container = await pool.acquire()
|
|
21
|
+
tracker = RequestFileTracker(container)
|
|
22
|
+
try:
|
|
23
|
+
uploaded = await tracker.upload_file("/tmp/input.csv")
|
|
24
|
+
# ... use container ...
|
|
25
|
+
finally:
|
|
26
|
+
await tracker.cleanup()
|
|
27
|
+
await pool.release(container)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, container: Container) -> None:
|
|
31
|
+
self._container = container
|
|
32
|
+
self._tracked_file_ids: list[str] = []
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def container(self) -> Container:
|
|
36
|
+
return self._container
|
|
37
|
+
|
|
38
|
+
async def upload_file(self, local_path: str) -> UploadedFile:
|
|
39
|
+
"""Upload a file and automatically track its file_id for cleanup."""
|
|
40
|
+
result = await self._container.upload_file(local_path)
|
|
41
|
+
self._tracked_file_ids.append(result.file_id)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
async def cleanup(self) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Delete all tracked files. Best-effort: always clears the tracking
|
|
47
|
+
list even if some deletes fail. Errors are logged, not raised.
|
|
48
|
+
"""
|
|
49
|
+
if not self._tracked_file_ids:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
ids_to_delete = list(self._tracked_file_ids)
|
|
53
|
+
self._tracked_file_ids.clear() # clear before deleting — safe on partial failure
|
|
54
|
+
|
|
55
|
+
await self._container.delete_files(ids_to_delete)
|
|
56
|
+
log.debug("RequestFileTracker cleaned up %d file(s).", len(ids_to_delete))
|
container_pool/_types.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ContainerStatus(StrEnum):
|
|
9
|
+
ACTIVE = "active"
|
|
10
|
+
EXPIRED = "expired"
|
|
11
|
+
UNKNOWN = "unknown"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ContainerInfo:
|
|
16
|
+
"""
|
|
17
|
+
Vendor-neutral snapshot of a container's identity and status.
|
|
18
|
+
Backends return this; the pool never touches raw vendor objects.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
container_id: str
|
|
22
|
+
status: ContainerStatus
|
|
23
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class UploadedFile:
|
|
28
|
+
"""
|
|
29
|
+
Returned by backend.upload_file(). Carries all three identifiers
|
|
30
|
+
the caller needs: the container it belongs to, the remote file id,
|
|
31
|
+
and the path the runtime sees it at.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
container_id: str
|
|
35
|
+
file_id: str
|
|
36
|
+
container_path: str
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import openai as _openai
|
|
7
|
+
from openai import AsyncOpenAI
|
|
8
|
+
except ImportError as exc:
|
|
9
|
+
raise ImportError(
|
|
10
|
+
"The OpenAI backend requires the 'openai' package. "
|
|
11
|
+
"Install it with: pip install 'container-pool[openai]'"
|
|
12
|
+
) from exc
|
|
13
|
+
|
|
14
|
+
from .._backend import BaseContainerBackend
|
|
15
|
+
from .._exceptions import (
|
|
16
|
+
ContainerCreationError,
|
|
17
|
+
ContainerExpiredError,
|
|
18
|
+
ContainerFileError,
|
|
19
|
+
)
|
|
20
|
+
from .._types import ContainerInfo, ContainerStatus, UploadedFile
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OpenAIContainerBackend(BaseContainerBackend):
|
|
24
|
+
"""
|
|
25
|
+
Concrete backend using the OpenAI Code Interpreter Container API.
|
|
26
|
+
|
|
27
|
+
Requires openai>=1.0.0 and an AsyncOpenAI client. Install the extra:
|
|
28
|
+
pip install 'container-pool[openai]'
|
|
29
|
+
|
|
30
|
+
All vendor-specific errors are translated into ContainerPoolError subclasses.
|
|
31
|
+
The pool and Container classes never see openai SDK types.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, client: AsyncOpenAI) -> None:
|
|
35
|
+
self._client = client
|
|
36
|
+
|
|
37
|
+
# -------------------------------------------------------------------------
|
|
38
|
+
# Lifecycle
|
|
39
|
+
# -------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
async def create_container(self, name: str) -> ContainerInfo:
|
|
42
|
+
try:
|
|
43
|
+
result = await self._client.containers.create(name=name)
|
|
44
|
+
return ContainerInfo(
|
|
45
|
+
container_id=result.id,
|
|
46
|
+
status=self._parse_status(result.status),
|
|
47
|
+
)
|
|
48
|
+
except _openai.APIError as exc:
|
|
49
|
+
raise ContainerCreationError(attempts=1, cause=exc) from exc
|
|
50
|
+
|
|
51
|
+
async def get_container(self, container_id: str) -> ContainerInfo:
|
|
52
|
+
try:
|
|
53
|
+
result = await self._client.containers.retrieve(container_id)
|
|
54
|
+
status = self._parse_status(result.status)
|
|
55
|
+
if status != ContainerStatus.ACTIVE:
|
|
56
|
+
raise ContainerExpiredError(container_id)
|
|
57
|
+
return ContainerInfo(container_id=result.id, status=status)
|
|
58
|
+
except _openai.NotFoundError as exc:
|
|
59
|
+
raise ContainerExpiredError(container_id) from exc
|
|
60
|
+
except ContainerExpiredError:
|
|
61
|
+
raise
|
|
62
|
+
except _openai.APIConnectionError as exc:
|
|
63
|
+
# Network error — treat as expired so the pool recreates
|
|
64
|
+
raise ContainerExpiredError(container_id) from exc
|
|
65
|
+
except _openai.APIError as exc:
|
|
66
|
+
raise ContainerExpiredError(container_id) from exc
|
|
67
|
+
|
|
68
|
+
async def destroy_container(self, container_id: str) -> None:
|
|
69
|
+
try:
|
|
70
|
+
await self._client.containers.delete(container_id)
|
|
71
|
+
except (_openai.NotFoundError, _openai.APIError):
|
|
72
|
+
pass # best-effort
|
|
73
|
+
|
|
74
|
+
# -------------------------------------------------------------------------
|
|
75
|
+
# File operations
|
|
76
|
+
# -------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async def upload_file(self, container_id: str, local_path: str) -> UploadedFile:
|
|
79
|
+
try:
|
|
80
|
+
with open(local_path, "rb") as f:
|
|
81
|
+
result = await self._client.containers.files.create(
|
|
82
|
+
container_id=container_id,
|
|
83
|
+
file=f,
|
|
84
|
+
)
|
|
85
|
+
return UploadedFile(
|
|
86
|
+
container_id=container_id,
|
|
87
|
+
file_id=result.id,
|
|
88
|
+
container_path=result.path,
|
|
89
|
+
)
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
raise
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
raise ContainerFileError(
|
|
94
|
+
f"Upload failed for {local_path!r}: {exc}"
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
async def download_file_content(self, container_id: str, file_id: str) -> bytes:
|
|
98
|
+
try:
|
|
99
|
+
response = await self._client.containers.files.content.retrieve(
|
|
100
|
+
file_id=file_id,
|
|
101
|
+
container_id=container_id,
|
|
102
|
+
)
|
|
103
|
+
return response.content
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
raise ContainerFileError(
|
|
106
|
+
f"Download failed for file {file_id!r}: {exc}"
|
|
107
|
+
) from exc
|
|
108
|
+
|
|
109
|
+
async def download_file_to_disk(
|
|
110
|
+
self,
|
|
111
|
+
container_id: str,
|
|
112
|
+
file_id: str,
|
|
113
|
+
local_path: str,
|
|
114
|
+
) -> int:
|
|
115
|
+
content = await self.download_file_content(container_id, file_id)
|
|
116
|
+
os.makedirs(os.path.dirname(local_path) or ".", exist_ok=True)
|
|
117
|
+
with open(local_path, "wb") as f:
|
|
118
|
+
f.write(content)
|
|
119
|
+
return len(content)
|
|
120
|
+
|
|
121
|
+
async def delete_file(self, container_id: str, file_id: str) -> None:
|
|
122
|
+
try:
|
|
123
|
+
await self._client.containers.files.delete(
|
|
124
|
+
file_id=file_id,
|
|
125
|
+
container_id=container_id,
|
|
126
|
+
)
|
|
127
|
+
except (_openai.NotFoundError, _openai.APIError):
|
|
128
|
+
pass # best-effort
|
|
129
|
+
|
|
130
|
+
async def list_files(
|
|
131
|
+
self,
|
|
132
|
+
container_id: str,
|
|
133
|
+
path_prefix: str = "",
|
|
134
|
+
) -> dict[str, str]:
|
|
135
|
+
try:
|
|
136
|
+
response = await self._client.containers.files.list(
|
|
137
|
+
container_id=container_id
|
|
138
|
+
)
|
|
139
|
+
files: dict[str, str] = {}
|
|
140
|
+
for f in response.data:
|
|
141
|
+
if path_prefix and not f.path.startswith(path_prefix):
|
|
142
|
+
continue
|
|
143
|
+
filename = os.path.basename(f.path)
|
|
144
|
+
files[filename] = f.id
|
|
145
|
+
return files
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
raise ContainerFileError(f"list_files failed: {exc}") from exc
|
|
148
|
+
|
|
149
|
+
# -------------------------------------------------------------------------
|
|
150
|
+
# Helpers
|
|
151
|
+
# -------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _parse_status(raw: str) -> ContainerStatus:
|
|
155
|
+
try:
|
|
156
|
+
return ContainerStatus(raw)
|
|
157
|
+
except ValueError:
|
|
158
|
+
return ContainerStatus.UNKNOWN
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: container-pool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Provider-agnostic async container pool with expiry recovery and per-request file tracking
|
|
5
|
+
Project-URL: Repository, https://github.com/aayushgzip/container-pool
|
|
6
|
+
Project-URL: Issues, https://github.com/aayushgzip/container-pool/issues
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 aayushdwids
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Keywords: async,asyncio,code-interpreter,container,openai,pool
|
|
30
|
+
Classifier: Development Status :: 4 - Beta
|
|
31
|
+
Classifier: Framework :: AsyncIO
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
38
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
39
|
+
Requires-Python: >=3.11
|
|
40
|
+
Provides-Extra: dev
|
|
41
|
+
Requires-Dist: build; extra == 'dev'
|
|
42
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
43
|
+
Requires-Dist: pytest-mock>=3.12; extra == 'dev'
|
|
44
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
45
|
+
Requires-Dist: twine; extra == 'dev'
|
|
46
|
+
Provides-Extra: openai
|
|
47
|
+
Requires-Dist: openai>=1.0.0; extra == 'openai'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# container-pool
|
|
51
|
+
|
|
52
|
+
Production-grade async container pool for Python. Handles container lifecycle, reuse, concurrency, and automatic recovery from expiry — so you don't have to.
|
|
53
|
+
|
|
54
|
+
Built for OpenAI's Code Interpreter, but designed to work with **any sandboxed container runtime** via a pluggable backend interface.
|
|
55
|
+
|
|
56
|
+
## The Problem
|
|
57
|
+
|
|
58
|
+
When running sandboxed containers behind a multi-user backend, you hit problems no provider solves for you:
|
|
59
|
+
|
|
60
|
+
- **Containers expire silently** after 20 minutes of inactivity. Your next request fails with a 404.
|
|
61
|
+
- **No built-in pooling.** Every request creates a new container (~2-3s overhead).
|
|
62
|
+
- **No concurrency management.** Two users hitting your API simultaneously? You're on your own.
|
|
63
|
+
- **File cleanup is your problem.** Leaked files accumulate and you eat the storage cost.
|
|
64
|
+
|
|
65
|
+
`container-pool` is the infrastructure layer that handles all of this.
|
|
66
|
+
|
|
67
|
+
## What This Does
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
Request A ──→ acquire() ──→ [Container 1] ──→ release() ──→ back to pool
|
|
71
|
+
Request B ──→ acquire() ──→ [Container 2] ──→ release() ──→ back to pool
|
|
72
|
+
Request C ──→ acquire() ──→ (pool full, blocks until release) ──→ ...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- **FIFO pool** with configurable size, blocking acquisition with timeout when exhausted
|
|
76
|
+
- **Automatic expiry recovery** — detects expired containers (404, status=expired) and transparently recreates them
|
|
77
|
+
- **Per-request file tracking** with cleanup, so containers stay clean between users
|
|
78
|
+
- **Retry with exponential backoff** on container creation failures
|
|
79
|
+
- **Graceful shutdown** that destroys all containers on exit
|
|
80
|
+
- **Provider-agnostic** — implement `BaseContainerBackend` to support any runtime
|
|
81
|
+
|
|
82
|
+
## Installation
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install container-pool # core only
|
|
86
|
+
pip install "container-pool[openai]" # with OpenAI backend
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from openai import AsyncOpenAI
|
|
93
|
+
from container_pool import ContainerPool, RequestFileTracker
|
|
94
|
+
from container_pool.backends.openai import OpenAIContainerBackend
|
|
95
|
+
|
|
96
|
+
client = AsyncOpenAI()
|
|
97
|
+
backend = OpenAIContainerBackend(client)
|
|
98
|
+
|
|
99
|
+
pool = ContainerPool(
|
|
100
|
+
backend,
|
|
101
|
+
max_pool_size=5,
|
|
102
|
+
acquire_timeout=30.0,
|
|
103
|
+
container_name="my-pool",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Acquire, use, release
|
|
107
|
+
container = await pool.acquire()
|
|
108
|
+
try:
|
|
109
|
+
tracker = RequestFileTracker(container)
|
|
110
|
+
uploaded = await tracker.upload_file("/tmp/data.csv")
|
|
111
|
+
# ... run code interpreter with container.container_id ...
|
|
112
|
+
files = await container.list_output_files("/mnt/data/")
|
|
113
|
+
results = await container.download_files(files, "/tmp/output")
|
|
114
|
+
finally:
|
|
115
|
+
await tracker.cleanup() # delete uploaded files
|
|
116
|
+
await pool.release(container) # return to pool
|
|
117
|
+
|
|
118
|
+
# On app shutdown
|
|
119
|
+
await pool.shutdown()
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Custom Backends
|
|
123
|
+
|
|
124
|
+
Implement `BaseContainerBackend` to plug in any container runtime:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from container_pool import BaseContainerBackend, ContainerInfo, UploadedFile
|
|
128
|
+
|
|
129
|
+
class MyBackend(BaseContainerBackend):
|
|
130
|
+
async def create_container(self, name: str) -> ContainerInfo: ...
|
|
131
|
+
async def get_container(self, container_id: str) -> ContainerInfo: ...
|
|
132
|
+
async def destroy_container(self, container_id: str) -> None: ...
|
|
133
|
+
async def upload_file(self, container_id: str, local_path: str) -> UploadedFile: ...
|
|
134
|
+
async def download_file_content(self, container_id: str, file_id: str) -> bytes: ...
|
|
135
|
+
async def download_file_to_disk(self, container_id: str, file_id: str, local_path: str) -> int: ...
|
|
136
|
+
async def delete_file(self, container_id: str, file_id: str) -> None: ...
|
|
137
|
+
async def list_files(self, container_id: str, path_prefix: str = "") -> dict[str, str]: ...
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## How It Works
|
|
141
|
+
|
|
142
|
+
### Acquire Flow
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
acquire()
|
|
146
|
+
├─ Queue has available container? → validate it's alive → return
|
|
147
|
+
├─ Pool below max size? → create new container → return
|
|
148
|
+
└─ Pool exhausted? → block until someone calls release() (with timeout)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Expiry Recovery
|
|
152
|
+
|
|
153
|
+
`container-pool` handles silent expiry transparently — callers always get a live container:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
validate_or_recreate(container)
|
|
157
|
+
├─ active status → use it
|
|
158
|
+
├─ expired status → recreate
|
|
159
|
+
├─ 404 → recreate
|
|
160
|
+
└─ connection error → recreate
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Performance
|
|
164
|
+
|
|
165
|
+
| Operation | Latency |
|
|
166
|
+
|---|---|
|
|
167
|
+
| Warm acquire | <100ms |
|
|
168
|
+
| Cold acquire | ~2-3s (container creation) |
|
|
169
|
+
| Pool exhausted | Blocks up to `acquire_timeout` |
|
|
170
|
+
| Expiry recovery | ~2-3s (transparent recreation) |
|
|
171
|
+
|
|
172
|
+
## Configuration
|
|
173
|
+
|
|
174
|
+
| Parameter | Description |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `max_pool_size` | Max containers in pool (1–50) |
|
|
177
|
+
| `acquire_timeout` | Seconds to wait when pool is exhausted |
|
|
178
|
+
| `container_name` | Name prefix for created containers |
|
|
179
|
+
| `creation_max_attempts` | Retry attempts on creation failure (default: 3) |
|
|
180
|
+
| `creation_base_delay` | Base delay for exponential backoff in seconds (default: 1.0) |
|
|
181
|
+
|
|
182
|
+
## Roadmap
|
|
183
|
+
|
|
184
|
+
### v1 (current)
|
|
185
|
+
- [x] FIFO pool with `asyncio.Queue`
|
|
186
|
+
- [x] Automatic expiry detection and recovery
|
|
187
|
+
- [x] Per-request file tracking and cleanup
|
|
188
|
+
- [x] Retry with exponential backoff
|
|
189
|
+
- [x] Graceful shutdown
|
|
190
|
+
- [x] Pluggable backend interface
|
|
191
|
+
- [x] OpenAI Code Interpreter backend
|
|
192
|
+
|
|
193
|
+
### v2
|
|
194
|
+
- [ ] **Pool pre-warming** — create containers at startup to eliminate cold-start latency
|
|
195
|
+
- [ ] **Background keep-alive** — periodic pings to prevent idle expiry
|
|
196
|
+
- [ ] **Distributed state** — Redis/PostgreSQL backend for multi-node deployments
|
|
197
|
+
- [ ] **Observability** — metrics for pool utilization, acquire wait times, expiry rate
|
|
198
|
+
- [ ] **Pool strategies** — LRU, priority-based in addition to FIFO
|
|
199
|
+
|
|
200
|
+
## Contributing
|
|
201
|
+
|
|
202
|
+
Contributions welcome. Please open an issue first to discuss what you'd like to change.
|
|
203
|
+
|
|
204
|
+
## Why This Exists
|
|
205
|
+
|
|
206
|
+
Built after hitting every one of these problems while running Code Interpreter in a multi-user production backend. OpenAI's docs hand you a container ID and say good luck — this is the "good luck" part.
|
|
207
|
+
|
|
208
|
+
— [@aayushgzip](https://github.com/aayushgzip)
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
container_pool/__init__.py,sha256=KaW0rh1XJ9lt1PYvrFtsNUc-LJnTK022ccU3m6J96VA,1311
|
|
2
|
+
container_pool/_backend.py,sha256=PJPKk_tp9HRQ_J7LejH7VxAGk13fiDH5H9SQ6Trn3PI,2946
|
|
3
|
+
container_pool/_container.py,sha256=03y3VC2OOknWmTpkgu-bjp3FpyEBZcuLlY4KfMMsWQg,3069
|
|
4
|
+
container_pool/_exceptions.py,sha256=CMCcPTwC3NMrfmZLFDxvOsz8mBc4tEQkC9t3szlSdwk,1484
|
|
5
|
+
container_pool/_pool.py,sha256=5yTkorgy1UpkFEJwE_mp3zQ6GFPiiHjwyJ9c8e2fmk4,7714
|
|
6
|
+
container_pool/_retry.py,sha256=c6ScGsCP8KPI49NalUDIWbfKsfLjg92d3eW3GkufVuU,1508
|
|
7
|
+
container_pool/_tracker.py,sha256=bzxzSr0Y9izCrbxaf3aRyC-T3LvZ0J3kXSYQs6qPwAk,1856
|
|
8
|
+
container_pool/_types.py,sha256=0h2Czgk5eysLvdFSSGa6I2msejs28-GfC7u7q8MTzec,844
|
|
9
|
+
container_pool/backends/__init__.py,sha256=Pv8ebNKK-lcPwymzyF5JKpvNJRYxgjEDDyfDL76-xaU,175
|
|
10
|
+
container_pool/backends/openai.py,sha256=sZKjtZkFuAttQ9tA464stmfVhgbEi5I4PuHODoYJuj8,5734
|
|
11
|
+
container_pool-0.1.0.dist-info/METADATA,sha256=fq23Vx8rkcerhltizaxMNODzPUdgkLNiKzP5OBO8cHk,8362
|
|
12
|
+
container_pool-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
13
|
+
container_pool-0.1.0.dist-info/licenses/LICENSE,sha256=5E2CMJpaO4JdczEgKliBHB2aSxCnkvyB5NNEtX0TgXY,1068
|
|
14
|
+
container_pool-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aayushdwids
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|