Simulate the race that existed before the fix: two threads concurrently modifying different fields of the same task.
| 173 | |
| 174 | |
| 175 | class TestConcurrentModifyTask: |
| 176 | """Simulate the race that existed before the fix: two threads |
| 177 | concurrently modifying different fields of the same task.""" |
| 178 | |
| 179 | def test_concurrent_writes_both_persist(self, monkeypatch, tmp_path): |
| 180 | """Two threads updating different scheduler fields should both |
| 181 | be visible in the final state (no lost updates).""" |
| 182 | task = { |
| 183 | 'id': 'T-RACE', 'title': '竞争测试', 'state': 'Doing', |
| 184 | 'org': '兵部', 'updatedAt': '2026-04-22T02:00:00Z', |
| 185 | '_scheduler': { |
| 186 | 'enabled': True, 'stallThresholdSec': 600, 'maxRetry': 2, |
| 187 | 'retryCount': 0, 'escalationLevel': 0, 'autoRollback': True, |
| 188 | 'lastProgressAt': '2026-04-22T02:00:00Z', 'stallSince': None, |
| 189 | 'lastDispatchStatus': 'idle', 'rollbackCount': 0, |
| 190 | 'field_a': 'initial_a', 'field_b': 'initial_b', |
| 191 | 'snapshot': {'state': 'Assigned', 'org': '尚书省', 'now': '', 'savedAt': '', 'note': 'init'}, |
| 192 | }, |
| 193 | } |
| 194 | srv, _, tasks_path = _setup_server(monkeypatch, tmp_path, [task]) |
| 195 | monkeypatch.setattr(srv, '_trigger_refresh', lambda: None) |
| 196 | |
| 197 | barrier = threading.Barrier(2, timeout=5) |
| 198 | errors = [] |
| 199 | |
| 200 | def update_field_a(): |
| 201 | try: |
| 202 | barrier.wait() |
| 203 | srv.modify_task('T-RACE', lambda t: t.setdefault('_scheduler', {}).update({'field_a': 'updated_a'})) |
| 204 | except Exception as e: |
| 205 | errors.append(e) |
| 206 | |
| 207 | def update_field_b(): |
| 208 | try: |
| 209 | barrier.wait() |
| 210 | srv.modify_task('T-RACE', lambda t: t.setdefault('_scheduler', {}).update({'field_b': 'updated_b'})) |
| 211 | except Exception as e: |
| 212 | errors.append(e) |
| 213 | |
| 214 | t1 = threading.Thread(target=update_field_a) |
| 215 | t2 = threading.Thread(target=update_field_b) |
| 216 | t1.start() |
| 217 | t2.start() |
| 218 | t1.join(timeout=10) |
| 219 | t2.join(timeout=10) |
| 220 | |
| 221 | assert not errors, f'Thread errors: {errors}' |
| 222 | |
| 223 | data = json.loads(tasks_path.read_text(encoding='utf-8')) |
| 224 | sched = data[0].get('_scheduler', {}) |
| 225 | |
| 226 | # With atomic modify_task, BOTH updates must be visible. |
| 227 | # The old load_tasks/save_tasks pattern would lose one. |
| 228 | assert sched['field_a'] == 'updated_a', \ |
| 229 | f'field_a lost: {sched.get("field_a")}' |
| 230 | assert sched['field_b'] == 'updated_b', \ |
| 231 | f'field_b lost: {sched.get("field_b")}' |
| 232 |
nothing calls this directly
no outgoing calls
no test coverage detected