|
7 | 7 |
|
8 | 8 | from __future__ import annotations |
9 | 9 |
|
| 10 | +import pickle |
10 | 11 | from unittest.mock import Mock |
11 | 12 | from uuid import uuid4 |
12 | 13 |
|
13 | 14 | import pytest |
14 | 15 |
|
15 | 16 | from ai.backend.agent.docker.kernel import DockerKernel |
| 17 | +from ai.backend.agent.resources import KernelResourceSpec |
16 | 18 | from ai.backend.agent.types import KernelOwnershipData |
17 | 19 | from ai.backend.common.docker import ImageRef |
18 | | -from ai.backend.common.types import AgentId, ContainerId, KernelId, SessionId |
| 20 | +from ai.backend.common.types import AgentId, ContainerId, KernelId, ResourceSlot, SessionId |
19 | 21 |
|
20 | 22 |
|
21 | 23 | @pytest.fixture |
@@ -80,3 +82,89 @@ def test_second_call_updates_both_sources(self, mock_kernel_obj: DockerKernel) - |
80 | 82 | mock_kernel_obj.set_container_id(second) |
81 | 83 | assert mock_kernel_obj.container_id == second |
82 | 84 | assert mock_kernel_obj["container_id"] == second |
| 85 | + |
| 86 | + |
| 87 | +@pytest.fixture |
| 88 | +def picklable_kernel_obj() -> DockerKernel: |
| 89 | + """ |
| 90 | + Create a DockerKernel whose state (apart from the excluded ``_docker`` |
| 91 | + reference) is fully picklable. |
| 92 | +
|
| 93 | + The shared ``mock_kernel_obj`` fixture uses ``Mock()`` for |
| 94 | + ``resource_spec``, which cannot be pickled. Pickling tests therefore |
| 95 | + need a kernel constructed with a real, lightweight ``KernelResourceSpec`` |
| 96 | + so the only non-picklable member is the intentionally-excluded |
| 97 | + ``_docker`` client. |
| 98 | + """ |
| 99 | + kernel_id = KernelId(uuid4()) |
| 100 | + session_id = SessionId(uuid4()) |
| 101 | + agent_id = AgentId("test-agent-id") |
| 102 | + ownership_data = KernelOwnershipData( |
| 103 | + kernel_id=kernel_id, |
| 104 | + session_id=session_id, |
| 105 | + agent_id=agent_id, |
| 106 | + ) |
| 107 | + image = ImageRef( |
| 108 | + name="test-image", |
| 109 | + project="test-project", |
| 110 | + registry="registry.local", |
| 111 | + tag="latest", |
| 112 | + architecture="x86_64", |
| 113 | + is_local=False, |
| 114 | + ) |
| 115 | + resource_spec = KernelResourceSpec( |
| 116 | + slots=ResourceSlot(), |
| 117 | + allocations={}, |
| 118 | + scratch_disk_size=0, |
| 119 | + ) |
| 120 | + return DockerKernel( |
| 121 | + ownership_data=ownership_data, |
| 122 | + network_id="test-network", |
| 123 | + image=image, |
| 124 | + version=1, |
| 125 | + network_driver="bridge", |
| 126 | + agent_config={}, |
| 127 | + resource_spec=resource_spec, |
| 128 | + service_ports=[], |
| 129 | + environ={}, |
| 130 | + data={}, |
| 131 | + docker=Mock(), |
| 132 | + ) |
| 133 | + |
| 134 | + |
| 135 | +class TestDockerKernelPickling: |
| 136 | + """ |
| 137 | + Tests locking the DockerKernel pickling invariant. |
| 138 | +
|
| 139 | + The agent marshals DockerKernel instances via pickle for RPC / recovery. |
| 140 | + The live ``_docker`` client (aiohttp-backed aiodocker.Docker) is NOT |
| 141 | + picklable, so ``__getstate__`` must drop it and ``__setstate__`` must |
| 142 | + tolerate its absence. The agent re-attaches the client after unpickling |
| 143 | + via ``attach_docker()``. These tests guard against future regressions |
| 144 | + that would accidentally put ``_docker`` back into the pickled payload. |
| 145 | + """ |
| 146 | + |
| 147 | + def test_pickle_round_trip_excludes_docker(self, picklable_kernel_obj: DockerKernel) -> None: |
| 148 | + """pickle.dumps/loads must succeed and drop the _docker reference.""" |
| 149 | + # The Mock() Docker client is not picklable; the test confirms that |
| 150 | + # __getstate__ drops it so pickling succeeds anyway, and that non- |
| 151 | + # excluded state is preserved across the round trip. |
| 152 | + cid = ContainerId("pickled-container-xyz") |
| 153 | + picklable_kernel_obj.set_container_id(cid) |
| 154 | + |
| 155 | + data = pickle.dumps(picklable_kernel_obj) |
| 156 | + restored = pickle.loads(data) |
| 157 | + |
| 158 | + assert isinstance(restored, DockerKernel) |
| 159 | + # _docker must not be carried across pickle; the agent re-attaches |
| 160 | + # it via attach_docker() on recovery. |
| 161 | + assert getattr(restored, "_docker", None) is None |
| 162 | + # Sanity check: non-excluded state survives the round trip. |
| 163 | + assert restored.container_id == cid |
| 164 | + assert restored["container_id"] == cid |
| 165 | + assert restored.network_driver == picklable_kernel_obj.network_driver |
| 166 | + |
| 167 | + def test_getstate_does_not_contain_docker_key(self, picklable_kernel_obj: DockerKernel) -> None: |
| 168 | + """__getstate__ output must not carry the live _docker reference.""" |
| 169 | + state = picklable_kernel_obj.__getstate__() |
| 170 | + assert "_docker" not in state |
0 commit comments