log-foundry 0.10.2.dev80__tar.gz → 0.10.2.dev81__tar.gz

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.
Files changed (59) hide show
  1. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/PKG-INFO +5 -2
  2. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/README.md +4 -1
  3. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/_lifecycle.py +99 -60
  5. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/config.py +12 -4
  6. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/worker.py +1 -1
  7. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/LICENSE +0 -0
  8. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/__init__.py +0 -0
  9. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/_diag.py +0 -0
  10. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/_fork.py +0 -0
  11. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/api.py +0 -0
  12. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/console.py +0 -0
  13. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/context.py +0 -0
  14. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/decorator.py +0 -0
  15. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/ids.py +0 -0
  16. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/model.py +0 -0
  17. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/py.typed +0 -0
  18. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/results.py +0 -0
  19. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sanitize.py +0 -0
  20. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/__init__.py +0 -0
  21. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/_batch.py +0 -0
  22. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/_chunk.py +0 -0
  23. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/_retry.py +0 -0
  24. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/_socket.py +0 -0
  25. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/_time.py +0 -0
  26. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/base.py +0 -0
  27. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/callback.py +0 -0
  28. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/clickhouse.py +0 -0
  29. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/datadog.py +0 -0
  30. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/elasticsearch.py +0 -0
  31. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/eventhubs.py +0 -0
  32. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/file.py +0 -0
  33. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/filtering.py +0 -0
  34. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/firehose.py +0 -0
  35. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/honeycomb.py +0 -0
  36. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/http.py +0 -0
  37. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/kafka.py +0 -0
  38. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/kinesis.py +0 -0
  39. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/logging_sink.py +0 -0
  40. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/logstash.py +0 -0
  41. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/loki.py +0 -0
  42. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/memory.py +0 -0
  43. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/mongodb.py +0 -0
  44. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/multi.py +0 -0
  45. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/nats.py +0 -0
  46. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/newrelic.py +0 -0
  47. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/null.py +0 -0
  48. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/postgres.py +0 -0
  49. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/pubsub.py +0 -0
  50. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/rabbitmq.py +0 -0
  51. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/redis.py +0 -0
  52. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/sentry.py +0 -0
  53. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/sns.py +0 -0
  54. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/splunk.py +0 -0
  55. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/sqlite.py +0 -0
  56. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/sqs.py +0 -0
  57. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/stdout.py +0 -0
  58. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/syslog.py +0 -0
  59. {log_foundry-0.10.2.dev80 → log_foundry-0.10.2.dev81}/src/log_foundry/sinks/transform.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev80
3
+ Version: 0.10.2.dev81
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -317,7 +317,10 @@ drain from ever running — the live sink never drained, and your own `atexit` h
317
317
  either. The grace is what recovers the slow-close case that the daemon flag alone would lose.
318
318
 
319
319
  `configure()` is still a startup call. It is not thread-safe, and a span finishing on another
320
- thread mid-swap may land on either sink.
320
+ thread mid-swap may land on either sink. The **closes** are the exception: every sink you pass
321
+ here is closed, however many swaps are outstanding and whatever races the call (SPEC-045). That
322
+ is a guarantee about closes, not about buffers — an event still in flight when a `configure()`
323
+ lands can arrive at a sink whose close has already run, and nothing owes that sink another.
321
324
 
322
325
  ### `@trace`
323
326
 
@@ -281,7 +281,10 @@ drain from ever running — the live sink never drained, and your own `atexit` h
281
281
  either. The grace is what recovers the slow-close case that the daemon flag alone would lose.
282
282
 
283
283
  `configure()` is still a startup call. It is not thread-safe, and a span finishing on another
284
- thread mid-swap may land on either sink.
284
+ thread mid-swap may land on either sink. The **closes** are the exception: every sink you pass
285
+ here is closed, however many swaps are outstanding and whatever races the call (SPEC-045). That
286
+ is a guarantee about closes, not about buffers — an event still in flight when a `configure()`
287
+ lands can arrive at a sink whose close has already run, and nothing owes that sink another.
285
288
 
286
289
  ### `@trace`
287
290
 
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev80"
23
+ version = "0.10.2.dev81"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -64,11 +64,11 @@ class _Lifecycle:
64
64
 
65
65
  **The states.** A process is in exactly one of these, and the field that decides is named:
66
66
 
67
- - **cold** — ``_worker is None`` and ``_orphan_sink is None``. Nothing has been logged, no
67
+ - **cold** — ``_worker is None`` and ``_orphan_owed`` is empty. Nothing has been logged, no
68
68
  thread exists, no sink is owed a close. ``configure()`` alone does not leave this state:
69
69
  it runs ``_ensure_sink()`` unconditionally, so a resolved sink is not evidence anything
70
70
  was written to it (SPEC-031 FR-006).
71
- - **orphan-only** — ``_orphan_sink`` names the sink a level call with no span actually
71
+ - **orphan-only** — ``_orphan_owed`` names every sink a level call with no span actually
72
72
  reached, and ``_worker`` is still ``None``. The close is owed to this path, and the
73
73
  ``atexit`` handler is armed by the emit that landed rather than by the sink existing.
74
74
  - **worker-backed** — ``_worker`` holds the process worker. It owns the drain, the sink's
@@ -149,13 +149,35 @@ class _Lifecycle:
149
149
  self._worker: Worker | None = None
150
150
  self._lock = threading.Lock()
151
151
  self._atexit_registered = False
152
- self._orphan_sink: Sink | None = None
152
+ self._orphan_owed: dict[int, Sink] = {}
153
153
  self._orphan_closed_sink: Sink | None = None
154
154
  self._orphan_stop = threading.Event()
155
155
  self._orphan_retired = False
156
156
  self._shutdown_running = 0
157
157
  self._late_worker: Worker | None = None
158
158
 
159
+ def take_orphan_owed(self) -> list[Sink]:
160
+ """Empties the owed-close record and returns what it held, in arming order.
161
+
162
+ A **transition**, not one of the four questions (arch §9.2): every caller that clears the
163
+ record is deciding who performs the closes it was holding, and reading-and-clearing in
164
+ one step is what stops two of them deciding the same sink is theirs.
165
+
166
+ Callers hold ``_lock``.
167
+
168
+ Args:
169
+ None.
170
+
171
+ Returns:
172
+ The sinks that were owed a close, oldest first.
173
+
174
+ Raises:
175
+ None.
176
+ """
177
+ owed = list(self._orphan_owed.values())
178
+ self._orphan_owed.clear()
179
+ return owed
180
+
159
181
  def worker_exists(self) -> Worker | None:
160
182
  """Existence — is there a worker at all, and therefore anything to do (arch §9.2).
161
183
 
@@ -676,7 +698,7 @@ def _inheritance_roots() -> list[object]:
676
698
  candidates = (
677
699
  config._live_config().sink,
678
700
  None if worker is None else worker.sink,
679
- _state._orphan_sink,
701
+ *_state._orphan_owed.values(),
680
702
  _state._orphan_closed_sink,
681
703
  )
682
704
  roots: list[object] = [found for found in candidates if found is not None]
@@ -1180,11 +1202,11 @@ def _get_worker() -> Worker:
1180
1202
  _state._worker = worker
1181
1203
  if _state._shutdown_running > 0:
1182
1204
  _state._late_worker = worker
1183
- owed = _state._orphan_sink
1184
- _state._orphan_sink = None
1185
- if owed is not None and owed is not worker.sink:
1186
- _state._orphan_closed_sink = owed
1187
- release(owed, detached=True)
1205
+ owed = _state.take_orphan_owed()
1206
+ for stale in owed:
1207
+ if stale is not worker.sink:
1208
+ _state._orphan_closed_sink = stale
1209
+ release(stale, detached=True)
1188
1210
  return worker
1189
1211
  def _register_exit_handler() -> None:
1190
1212
  """Registers the one ``atexit`` handler that covers both delivery paths (SPEC-031 FR-006).
@@ -1241,14 +1263,16 @@ def _note_orphan_emit(sink: Sink) -> None:
1241
1263
  Raises:
1242
1264
  None.
1243
1265
  """
1244
- if (sink is _state._orphan_sink or sink is _state._orphan_closed_sink) and not _state._orphan_stop.is_set():
1266
+ if (
1267
+ id(sink) in _state._orphan_owed or sink is _state._orphan_closed_sink
1268
+ ) and not _state._orphan_stop.is_set():
1245
1269
  return
1246
1270
  with _state._lock:
1247
1271
  _offer_orphan_signal(sink)
1248
- if sink is _state._orphan_sink or sink is _state._orphan_closed_sink:
1272
+ if id(sink) in _state._orphan_owed or sink is _state._orphan_closed_sink:
1249
1273
  return
1250
1274
  _register_exit_handler()
1251
- _state._orphan_sink = sink
1275
+ _state._orphan_owed[id(sink)] = sink
1252
1276
  def _rebuild_worker_after_fork() -> None:
1253
1277
  """Gives a forked child a drain thread of its own, or a retired worker (SPEC-039 FR-002).
1254
1278
 
@@ -1382,17 +1406,17 @@ def _close_orphan_sink() -> None:
1382
1406
  itself. ``Exception``, never ``BaseException`` (SPEC-025 FR-004).
1383
1407
  """
1384
1408
  with _state._lock:
1385
- owed = _state._orphan_sink
1386
- if owed is None:
1387
- return
1388
- if _state.worker_owns(owed):
1389
- return
1390
- _state._orphan_sink = None
1391
- _state._orphan_closed_sink = owed
1392
- try:
1393
- release(owed)
1394
- except Exception as exc:
1395
- _diag.absorbed("closing the sink", exc, "it may still hold its resources")
1409
+ owed: list[Sink] = [
1410
+ sink for sink in _state._orphan_owed.values() if not _state.worker_owns(sink)
1411
+ ]
1412
+ for sink in owed:
1413
+ del _state._orphan_owed[id(sink)]
1414
+ _state._orphan_closed_sink = sink
1415
+ for sink in owed:
1416
+ try:
1417
+ release(sink)
1418
+ except Exception as exc:
1419
+ _diag.absorbed("closing the sink", exc, "it may still hold its resources")
1396
1420
  def _shutdown_worker(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
1397
1421
  """Drains and closes the process worker, or closes an orphan-only sink, backing ``shutdown()``.
1398
1422
 
@@ -1525,7 +1549,7 @@ def _swap_sink(new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) ->
1525
1549
  reports nothing at all, by the decision that made the bounded close available.
1526
1550
 
1527
1551
  **The worker branch latches what it hands over, and decides the close before clearing**
1528
- (SPEC-044 FR-004, FR-002). It used to clear ``_state._orphan_sink`` and record nothing: an
1552
+ (SPEC-044 FR-004, FR-002). It used to clear the orphan record and record nothing: an
1529
1553
  orphan emit that resolved the old sink before the swap and resumed after it then re-armed a
1530
1554
  sink :meth:`Worker.swap_sink` had already closed, and the exit close performed a second
1531
1555
  ``close()`` on it — measured ``A.closed == 2`` with a preemption point injected at
@@ -1563,27 +1587,33 @@ def _swap_sink(new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) ->
1563
1587
  rejected ceiling, and a sink swap that fails must not become the reason an application
1564
1588
  cannot start.
1565
1589
  """
1566
- closer = None
1590
+ closers: list[threading.Thread] = []
1567
1591
  deadline = None if timeout is None else monotonic() + timeout
1568
1592
  with _state._lock:
1569
1593
  worker = _state.live_worker()
1570
1594
  if worker is not None:
1571
- owed = _state._orphan_sink
1572
- _state._orphan_sink = None
1573
- if owed is not None and owed is not new_sink and owed is not worker.sink:
1574
- _state._orphan_closed_sink = owed
1575
- closer = release(owed, detached=True)
1595
+ for stale in _state.take_orphan_owed():
1596
+ if stale is not new_sink and stale is not worker.sink:
1597
+ _state._orphan_closed_sink = stale
1598
+ closer = release(stale, detached=True)
1599
+ if closer is not None:
1600
+ closers.append(closer)
1576
1601
  if worker.sink is not new_sink:
1577
1602
  _state._orphan_closed_sink = worker.sink
1578
1603
  else:
1579
- old = _state._orphan_sink
1580
- if old is None or old is new_sink:
1604
+ if not _state._orphan_owed:
1581
1605
  return
1582
- _state._orphan_sink = new_sink
1606
+ superseded = [
1607
+ sink for sink in _state.take_orphan_owed() if sink is not new_sink
1608
+ ]
1609
+ _state._orphan_owed[id(new_sink)] = new_sink
1583
1610
  _offer_orphan_signal(new_sink)
1584
- if not _state.worker_owns(old):
1585
- _state._orphan_closed_sink = old
1586
- closer = release(old, detached=True)
1611
+ for stale in superseded:
1612
+ if not _state.worker_owns(stale):
1613
+ _state._orphan_closed_sink = stale
1614
+ closer = release(stale, detached=True)
1615
+ if closer is not None:
1616
+ closers.append(closer)
1587
1617
  if worker is not None:
1588
1618
  try:
1589
1619
  worker_holds_sink = worker.swap_sink(new_sink, timeout)
@@ -1594,13 +1624,13 @@ def _swap_sink(new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) ->
1594
1624
  else:
1595
1625
  if not worker_holds_sink:
1596
1626
  _adopt_declined_swap(new_sink)
1597
- if closer is not None:
1627
+ for closer in closers:
1598
1628
  closer.join(None if deadline is None else max(0.0, deadline - monotonic()))
1599
1629
  def _adopt_declined_swap(new_sink: Sink) -> None:
1600
1630
  """Takes ownership of a sink a worker refused mid-swap (SPEC-035 FR-003).
1601
1631
 
1602
1632
  ``Worker.swap_sink`` re-checks retirement after its first ``flush()`` and returns early once
1603
- ``_shutdown_done`` latched, but :func:`_swap_sink` had already cleared ``_state._orphan_sink`` on the
1633
+ ``_shutdown_done`` latched, but :func:`_swap_sink` had already taken the orphan record on the
1604
1634
  strength of a worker being live a few instructions earlier. The new sink then sat in the
1605
1635
  config, installed nowhere and recorded nowhere: measured, ``config.sink is B`` was ``True``,
1606
1636
  ``B`` was never closed, and ``health()`` read entirely clean — for a sink whose ``close()``
@@ -1616,7 +1646,7 @@ def _adopt_declined_swap(new_sink: Sink) -> None:
1616
1646
 
1617
1647
  **A sink already recorded as closed is refused re-arming**, the guard
1618
1648
  :func:`_note_orphan_emit` carries and for its reason. Without it, an orphan log arming
1619
- ``_state._orphan_sink`` while this thread is inside ``swap_sink``'s first drain, followed by a
1649
+ ``_state._orphan_owed`` while this thread is inside ``swap_sink``'s first drain, followed by a
1620
1650
  ``shutdown()`` that closes it, lets this re-arm a closed sink for a second ``close()`` at
1621
1651
  exit — reproduced. ``sinks/base.py`` asks an implementation to make its release idempotent,
1622
1652
  but the library does not rely on it, for the reason :func:`_swap_sink` states.
@@ -1636,10 +1666,10 @@ def _adopt_declined_swap(new_sink: Sink) -> None:
1636
1666
  """
1637
1667
  with _state._lock:
1638
1668
  _offer_orphan_signal(new_sink)
1639
- if new_sink is _state._orphan_sink or new_sink is _state._orphan_closed_sink:
1669
+ if id(new_sink) in _state._orphan_owed or new_sink is _state._orphan_closed_sink:
1640
1670
  return
1641
1671
  _register_exit_handler()
1642
- _state._orphan_sink = new_sink
1672
+ _state._orphan_owed[id(new_sink)] = new_sink
1643
1673
  def _flush_live_sink() -> bool:
1644
1674
  """Drains whatever the delivering sink holds in its own client (SPEC-036 FR-002).
1645
1675
 
@@ -1669,15 +1699,21 @@ def _flush_live_sink() -> bool:
1669
1699
  from log_foundry.sinks.base import flush_sink
1670
1700
 
1671
1701
  worker = _state.live_worker()
1672
- sink = worker.sink if worker is not None else _state._orphan_sink
1673
- if sink is None:
1702
+ if worker is not None:
1703
+ pending = [worker.sink]
1704
+ else:
1705
+ with _state._lock:
1706
+ pending = list(_state._orphan_owed.values())
1707
+ if not pending:
1674
1708
  return True
1675
- try:
1676
- flush_sink(sink)
1677
- except Exception as exc:
1678
- _diag.absorbed("flushing the sink's own buffer", exc, "its client still holds events")
1679
- return False
1680
- return True
1709
+ drained = True
1710
+ for sink in pending:
1711
+ try:
1712
+ flush_sink(sink)
1713
+ except Exception as exc:
1714
+ _diag.absorbed("flushing the sink's own buffer", exc, "its client still holds events")
1715
+ drained = False
1716
+ return drained
1681
1717
  def _flush_worker(timeout: float | None = 5.0) -> FlushResult:
1682
1718
  """Drains the process worker without retiring it, backing ``flush()`` (SPEC-013 FR-003).
1683
1719
 
@@ -1745,16 +1781,18 @@ def _delivering_to_an_inherited_sink() -> bool:
1745
1781
  and for the same reason: standing up a thread to answer ``health()`` is forbidden.
1746
1782
 
1747
1783
  The three candidates are asked in delivery order: the worker's sink if a worker exists,
1748
- else the sink an orphan emit reached, else the configured one. SPEC-033's measured
1749
- disagreement is worker-versus-config, which the **first** term already covers — and the
1750
- second cannot currently produce a distinct answer, because with no worker ``_state._orphan_sink``
1751
- is either ``None`` or the same object the config holds (``_swap_sink`` re-points both, and
1752
- where it returns early the record is ``None``). It is kept rather than removed, and recorded
1753
- rather than tested: every other orphan-path read in this module treats ``_state._orphan_sink`` as
1754
- the authority on which sink an emit reached, and dropping it here would make one site read
1755
- the config while its neighbours read the record — resting on a property of ``_swap_sink``'s
1756
- current body that nothing states as an invariant. A test would have to arrange an
1757
- unreachable state by hand, which is the fixture-built vacuity this repo keeps finding.
1784
+ else the sink an orphan emit reached most recently, else the configured one. SPEC-033's
1785
+ measured disagreement is worker-versus-config, which the **first** term already covers.
1786
+
1787
+ The middle term takes the **last** entry of ``_state._orphan_owed``, and that is continuity
1788
+ with the single slot it replaced rather than a claim that the last entry is the sink being
1789
+ delivered to. It is not: ``_swap_sink`` inserts the new sink into a freshly emptied record and
1790
+ a preempted emit then appends the *superseded* one, so in this spec's own primary scenario the
1791
+ order is ``[live, superseded]``. Neither end of the record is authoritative for "installed" —
1792
+ arming order is emit order, which is a different question — and the config is. The answer is
1793
+ therefore unchanged from before SPEC-045 and can still name a superseded sink; that limit is
1794
+ recorded in ``architecture.md`` §13 rather than quietly fixed here, because correcting it
1795
+ changes a documented ``Health`` field on a path this spec does not otherwise touch.
1758
1796
 
1759
1797
  With no sink resolved at all there is nothing installed and nothing inherited, so the answer
1760
1798
  is ``False`` rather than a guess.
@@ -1774,7 +1812,8 @@ def _delivering_to_an_inherited_sink() -> bool:
1774
1812
 
1775
1813
  try:
1776
1814
  worker = _state.worker_exists()
1777
- sink = worker.sink if worker is not None else (_state._orphan_sink or _live_config().sink)
1815
+ owed = next(reversed(_state._orphan_owed.values()), None)
1816
+ sink = worker.sink if worker is not None else (owed or _live_config().sink)
1778
1817
  return sink is not None and not releasable(sink)
1779
1818
  except Exception:
1780
1819
  return False
@@ -110,8 +110,14 @@ def configure(
110
110
  (arch §7). It **swaps the live delivery target**: the previous sink is closed and subsequent
111
111
  events go to the new one, so "repeated calls compose rather than reset" holds for the sink
112
112
  too, at the cost of one bounded wait. Passing the sink that is already live is a no-op: no
113
- drain, no close. The previous sink is closed and must not be handed back to a later call —
114
- doing so closes it twice.
113
+ drain, no close.
114
+
115
+ **Every sink this has been given is closed** (SPEC-045), whatever races the call and however
116
+ many are outstanding. Handing the previous sink back to a later call is therefore supported
117
+ rather than forbidden: it is closed again, because it took events again, and what it took
118
+ after the hand-back is flushed by that second close. The record of which sinks are owed a
119
+ close used to be a single slot, so an ordinary ``info()`` on another thread could leave the
120
+ **live** sink closed by nobody and its buffer undelivered.
115
121
 
116
122
  **What the swap promises differs by delivery path**, because the two have different work to
117
123
  do. With a background worker — anything using ``@trace`` — everything submitted so far is
@@ -129,8 +135,10 @@ def configure(
129
135
  on in the background. An expired join reports nothing, since a slow close and a stuck one
130
136
  cannot be told apart at that moment; ``health().closing_sinks`` reports the live fact instead.
131
137
 
132
- This is still a startup call. It is not thread-safe, and a span finishing on another thread
133
- during a swap may land on either sink.
138
+ This is still a startup call, and it is **not thread-safe**. A span finishing on another
139
+ thread during a swap may land on either sink, and two threads calling this at once can leave
140
+ the config naming one sink while delivery goes to another. What is unconditional is that
141
+ every sink involved is closed; where its events went is not.
134
142
 
135
143
  Args:
136
144
  service: The service name stamped onto every event.
@@ -1020,7 +1020,7 @@ class Worker:
1020
1020
  after ``_run`` has returned, so the sink is provably out of use *by the worker*.
1021
1021
 
1022
1022
  **It records nothing in the closed-sink latch, and does not need to** (SPEC-044 FR-004):
1023
- ``_lifecycle._orphan_sink`` still names this sink where anything named it, and
1023
+ ``_lifecycle._orphan_owed`` still names this sink where anything named it, and
1024
1024
  ``worker_owns`` answers ``True``, so ``_close_orphan_sink`` declines rather than re-arming
1025
1025
  it. The latch exists for a sink this worker has *stopped* holding.
1026
1026