|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import asyncio |
| 6 | +import threading |
5 | 7 | from datetime import UTC, datetime |
6 | 8 |
|
7 | 9 | from opentelemetry.sdk.trace import IdGenerator, RandomIdGenerator |
@@ -203,3 +205,78 @@ def test_deterministic_id_generator_prefers_next_span_id_over_fallback(): |
203 | 205 | assert generator.generate_span_id() == deterministic_span_id |
204 | 206 | # Subsequent calls fall back to the provided generator. |
205 | 207 | assert generator.generate_span_id() == int("b" * 16, 16) |
| 208 | + |
| 209 | + |
| 210 | +def test_pending_span_id_is_isolated_across_threads(): |
| 211 | + """Verify a span ID set in one thread is not consumed by another thread. |
| 212 | +
|
| 213 | + The pending span ID is stored in a ContextVar, so each worker thread has |
| 214 | + its own value. Without this isolation a concurrent operation could steal |
| 215 | + another operation's deterministic span ID, producing the wrong span ID. |
| 216 | + """ |
| 217 | + random_span_id = int("f" * 16, 16) |
| 218 | + fallback = _StubIdGenerator(trace_id=int("a" * 32, 16), span_id=random_span_id) |
| 219 | + generator = DeterministicIdGenerator(fallback_id_generator=fallback) |
| 220 | + |
| 221 | + # The main thread sets a deterministic span ID but never consumes it. |
| 222 | + main_deterministic_span_id = int("1" * 16, 16) |
| 223 | + generator.set_next_span_id(main_deterministic_span_id) |
| 224 | + |
| 225 | + barrier = threading.Barrier(2) |
| 226 | + results: dict[str, int] = {} |
| 227 | + |
| 228 | + def worker(name: str, span_id: int) -> None: |
| 229 | + # Each worker starts with a fresh context (default None), so it must |
| 230 | + # not see the main thread's pending span ID. |
| 231 | + barrier.wait() |
| 232 | + results[f"{name}-before-set"] = generator.generate_span_id() |
| 233 | + generator.set_next_span_id(span_id) |
| 234 | + results[f"{name}-after-set"] = generator.generate_span_id() |
| 235 | + |
| 236 | + worker_a_span_id = int("2" * 16, 16) |
| 237 | + worker_b_span_id = int("3" * 16, 16) |
| 238 | + thread_a = threading.Thread(target=worker, args=("a", worker_a_span_id)) |
| 239 | + thread_b = threading.Thread(target=worker, args=("b", worker_b_span_id)) |
| 240 | + thread_a.start() |
| 241 | + thread_b.start() |
| 242 | + thread_a.join() |
| 243 | + thread_b.join() |
| 244 | + |
| 245 | + # Workers never observed the main thread's value; they fell back to random. |
| 246 | + assert results["a-before-set"] == random_span_id |
| 247 | + assert results["b-before-set"] == random_span_id |
| 248 | + # Each worker consumed only its own deterministic span ID. |
| 249 | + assert results["a-after-set"] == worker_a_span_id |
| 250 | + assert results["b-after-set"] == worker_b_span_id |
| 251 | + # The main thread's pending span ID was untouched by the workers. |
| 252 | + assert generator.generate_span_id() == main_deterministic_span_id |
| 253 | + |
| 254 | + |
| 255 | +def test_pending_span_id_is_isolated_across_async_tasks(): |
| 256 | + """Verify a span ID set in one async task is not consumed by another. |
| 257 | +
|
| 258 | + Each asyncio task runs with its own copied context, so the pending span ID |
| 259 | + stays scoped to the task that set it even across await boundaries on the |
| 260 | + same thread. |
| 261 | + """ |
| 262 | + fallback_span_id = int("e" * 16, 16) |
| 263 | + fallback = _StubIdGenerator(trace_id=int("a" * 32, 16), span_id=fallback_span_id) |
| 264 | + generator = DeterministicIdGenerator(fallback_id_generator=fallback) |
| 265 | + |
| 266 | + task_a_span_id = int("4" * 16, 16) |
| 267 | + task_b_span_id = int("5" * 16, 16) |
| 268 | + |
| 269 | + async def task(span_id: int) -> int: |
| 270 | + generator.set_next_span_id(span_id) |
| 271 | + # Yield control so the other task interleaves between set and consume. |
| 272 | + await asyncio.sleep(0) |
| 273 | + return generator.generate_span_id() |
| 274 | + |
| 275 | + async def main() -> tuple[int, int]: |
| 276 | + return await asyncio.gather(task(task_a_span_id), task(task_b_span_id)) |
| 277 | + |
| 278 | + result_a, result_b = asyncio.run(main()) |
| 279 | + |
| 280 | + # Despite interleaving, each task consumed only its own deterministic ID. |
| 281 | + assert result_a == task_a_span_id |
| 282 | + assert result_b == task_b_span_id |
0 commit comments