|
7 | 7 | - the free-text grammar: terms AND in any order, "quoted phrase", -exclusion |
8 | 8 | """ |
9 | 9 |
|
| 10 | +import asyncio |
10 | 11 | import os |
11 | 12 |
|
12 | 13 | import pytest |
|
18 | 19 |
|
19 | 20 | import models # noqa: F401 registers cloud tables on the shared Base |
20 | 21 | from oddish.core.endpoints import browse_tasks_core |
| 22 | +from oddish.core.endpoints.deletion import delete_experiment_core |
| 23 | +from oddish.core.task_browse_summary import refresh_task_browse_summaries |
21 | 24 | from oddish.db.models import Base |
22 | 25 |
|
23 | 26 | URL = os.environ.get("ODDISH_DATABASE_URL") |
@@ -139,6 +142,117 @@ async def test_combine_copies_excluded_from_browse(): |
139 | 142 | await engine.dispose() |
140 | 143 |
|
141 | 144 |
|
| 145 | +async def test_experiment_delete_refreshes_surviving_task_summaries(): |
| 146 | + engine = create_async_engine(URL) |
| 147 | + maker = async_sessionmaker(engine, expire_on_commit=False) |
| 148 | + try: |
| 149 | + await _setup_combine(engine) |
| 150 | + async with maker() as session: |
| 151 | + # A real (non-combine) exp-b trial so t-c outlives exp-a's delete. |
| 152 | + await session.execute( |
| 153 | + text( |
| 154 | + """ |
| 155 | + INSERT INTO trials ( |
| 156 | + id, name, task_id, task_version_id, experiment_id, org_id, |
| 157 | + agent, model, provider, queue_key, timeout_minutes, |
| 158 | + environment, harbor_config, status, origin, is_probe, |
| 159 | + reward, finished_at, attempts, max_attempts, |
| 160 | + heartbeat_failure_count, has_trajectory, created_at, updated_at |
| 161 | + ) VALUES ( |
| 162 | + 'tr-b', 'tr-b', 't-c', 'v-c', 'exp-b', 'org1', |
| 163 | + 'claude', 'sonnet', 'anthropic', 'q', 30, 'modal', |
| 164 | + '{}'::jsonb, 'SUCCESS', 'oddish', false, 1.0, NOW(), |
| 165 | + 1, 6, 0, false, NOW(), NOW() |
| 166 | + ) |
| 167 | + """ |
| 168 | + ) |
| 169 | + ) |
| 170 | + await refresh_task_browse_summaries(session, ["v-c"]) |
| 171 | + await session.commit() |
| 172 | + summary_row = text( |
| 173 | + """ |
| 174 | + SELECT total_trials, completed_trials |
| 175 | + FROM task_version_browse_summaries |
| 176 | + WHERE task_version_id = 'v-c' |
| 177 | + """ |
| 178 | + ) |
| 179 | + before = (await session.execute(summary_row)).one() |
| 180 | + assert before == (2, 2) # tr-src + tr-b; the combine copy excluded |
| 181 | + |
| 182 | + await delete_experiment_core( |
| 183 | + session, experiment_id="exp-a", org_id=ORG |
| 184 | + ) |
| 185 | + await session.commit() |
| 186 | + after = (await session.execute(summary_row)).one() |
| 187 | + |
| 188 | + # The surviving task's summary must drop exp-a's tombstoned trial in |
| 189 | + # the delete transaction itself, and exp-b's combine copy must stay |
| 190 | + # excluded. |
| 191 | + assert after == (1, 1) |
| 192 | + finally: |
| 193 | + await engine.dispose() |
| 194 | + |
| 195 | + |
| 196 | +async def test_summary_refresh_serializes_concurrent_settlements(): |
| 197 | + engine = create_async_engine(URL) |
| 198 | + maker = async_sessionmaker(engine, expire_on_commit=False) |
| 199 | + try: |
| 200 | + await _setup_combine(engine) |
| 201 | + async with maker() as first, maker() as second: |
| 202 | + insert_trial = text( |
| 203 | + """ |
| 204 | + INSERT INTO trials ( |
| 205 | + id, name, task_id, task_version_id, experiment_id, org_id, |
| 206 | + agent, model, provider, queue_key, timeout_minutes, |
| 207 | + environment, harbor_config, status, origin, is_probe, |
| 208 | + reward, finished_at, attempts, max_attempts, |
| 209 | + heartbeat_failure_count, has_trajectory, created_at, updated_at |
| 210 | + ) VALUES ( |
| 211 | + :trial_id, :trial_id, 't-c', 'v-c', 'exp-a', 'org1', |
| 212 | + 'claude', 'sonnet', 'anthropic', 'q', 30, 'modal', |
| 213 | + '{}'::jsonb, 'SUCCESS', 'oddish', false, 1, NOW(), |
| 214 | + 1, 6, 0, false, NOW(), NOW() |
| 215 | + ) |
| 216 | + """ |
| 217 | + ) |
| 218 | + # Both inserts hold a KEY SHARE FK lock on v-c. Summary |
| 219 | + # serialization must not try to upgrade either lock to FOR UPDATE. |
| 220 | + await first.execute(insert_trial, {"trial_id": "tr-concurrent-a"}) |
| 221 | + await second.execute(insert_trial, {"trial_id": "tr-concurrent-b"}) |
| 222 | + # Generous budget: this only has to prove the holder does not |
| 223 | + # BLOCK (a lock-ordering bug hangs it forever); a tight budget |
| 224 | + # would flake on slow CI runners. |
| 225 | + await asyncio.wait_for( |
| 226 | + refresh_task_browse_summaries(first, ["v-c"]), timeout=5.0 |
| 227 | + ) |
| 228 | + |
| 229 | + second_refresh = asyncio.create_task( |
| 230 | + refresh_task_browse_summaries(second, ["v-c"]) |
| 231 | + ) |
| 232 | + with pytest.raises(asyncio.TimeoutError): |
| 233 | + await asyncio.wait_for(asyncio.shield(second_refresh), timeout=0.1) |
| 234 | + |
| 235 | + await first.commit() |
| 236 | + await second_refresh |
| 237 | + await second.commit() |
| 238 | + |
| 239 | + async with maker() as check: |
| 240 | + row = ( |
| 241 | + await check.execute( |
| 242 | + text( |
| 243 | + """ |
| 244 | + SELECT total_trials, completed_trials |
| 245 | + FROM task_version_browse_summaries |
| 246 | + WHERE task_version_id = 'v-c' |
| 247 | + """ |
| 248 | + ) |
| 249 | + ) |
| 250 | + ).one() |
| 251 | + assert row == (3, 3) |
| 252 | + finally: |
| 253 | + await engine.dispose() |
| 254 | + |
| 255 | + |
142 | 256 | async def test_search_wildcards_are_literals(): |
143 | 257 | engine = create_async_engine(URL) |
144 | 258 | maker = async_sessionmaker(engine, expire_on_commit=False) |
|
0 commit comments