log-foundry 0.10.2.dev37__tar.gz → 0.10.2.dev39__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 (56) hide show
  1. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/pyproject.toml +1 -1
  3. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/decorator.py +71 -4
  4. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/worker.py +85 -9
  5. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/LICENSE +0 -0
  6. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/README.md +0 -0
  7. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/__init__.py +0 -0
  8. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/_diag.py +0 -0
  9. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/_lifecycle.py +0 -0
  10. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/api.py +0 -0
  11. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/config.py +0 -0
  12. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/console.py +0 -0
  13. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/context.py +0 -0
  14. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/ids.py +0 -0
  15. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/model.py +0 -0
  16. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/py.typed +0 -0
  17. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sanitize.py +0 -0
  18. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/__init__.py +0 -0
  19. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/_batch.py +0 -0
  20. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/_chunk.py +0 -0
  21. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/_retry.py +0 -0
  22. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/_socket.py +0 -0
  23. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/_time.py +0 -0
  24. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/base.py +0 -0
  25. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/callback.py +0 -0
  26. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/clickhouse.py +0 -0
  27. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/datadog.py +0 -0
  28. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/elasticsearch.py +0 -0
  29. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/eventhubs.py +0 -0
  30. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/file.py +0 -0
  31. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/filtering.py +0 -0
  32. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/firehose.py +0 -0
  33. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/honeycomb.py +0 -0
  34. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/http.py +0 -0
  35. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/kafka.py +0 -0
  36. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/kinesis.py +0 -0
  37. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/logging_sink.py +0 -0
  38. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/logstash.py +0 -0
  39. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/loki.py +0 -0
  40. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/mongodb.py +0 -0
  41. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/multi.py +0 -0
  42. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/nats.py +0 -0
  43. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/newrelic.py +0 -0
  44. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/postgres.py +0 -0
  45. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/pubsub.py +0 -0
  46. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/rabbitmq.py +0 -0
  47. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/redis.py +0 -0
  48. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/sentry.py +0 -0
  49. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/sns.py +0 -0
  50. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/splunk.py +0 -0
  51. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/sqlite.py +0 -0
  52. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/sqs.py +0 -0
  53. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/stdout.py +0 -0
  54. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/syslog.py +0 -0
  55. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/transform.py +0 -0
  56. {log_foundry-0.10.2.dev37 → log_foundry-0.10.2.dev39}/src/log_foundry/sinks/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev37
3
+ Version: 0.10.2.dev39
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -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.dev37"
23
+ version = "0.10.2.dev39"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -350,6 +350,21 @@ def _offer_orphan_signal(sink: Sink) -> None:
350
350
  leave the drain thread serving a full backoff across ``Worker.shutdown``'s join, which is the
351
351
  global pause SPEC-027 exists to remove.
352
352
 
353
+ Ownership alone is not the whole predicate either, and this site is the one place in the
354
+ module where neither of the two categories fits (SPEC-035 FR-001). :func:`_live_worker` was
355
+ wrong: ``retired`` latches on **entry** to ``Worker.shutdown``, so for the whole of the drain
356
+ the skip stopped applying and an orphan log handed the sink a fresh unset event — precisely
357
+ the one the drain thread was about to wait on. Measured, a 20 s backoff against
358
+ ``shutdown(timeout=3)`` was then still outstanding when the shutdown expired at 3.01 s with
359
+ the sink left open — the wait was never cut, not merely slow. But bare ``_worker.sink is
360
+ sink`` is wrong in the opposite direction: it skips for a worker whose shutdown has
361
+ **finished**, leaving a sink still being written to holding a set event that can never clear,
362
+ which is SPEC-033 FR-004's tight retry loop and is covered by a test that spec shipped. The
363
+ predicate is therefore the ownership term **conjoined with** ``Worker.draining`` — the
364
+ moment as well as the identity, since without the identity an orphan log to sink Y would be
365
+ skipped merely because a live worker is draining into sink X. That property's docstring
366
+ carries the rest of the reasoning.
367
+
353
368
  A fresh event replaces one that is already set, because an ``Event`` never clears and
354
369
  ``sinks/_retry.wait`` returns immediately on a set one — a sink handed the shutdown's event
355
370
  would have every subsequent backoff collapsed to zero, which against a rate-limited
@@ -368,8 +383,8 @@ def _offer_orphan_signal(sink: Sink) -> None:
368
383
  None.
369
384
  """
370
385
  global _orphan_stop
371
- worker = _live_worker()
372
- if worker is not None and worker.sink is sink:
386
+ worker = _worker
387
+ if worker is not None and worker.sink is sink and worker.draining:
373
388
  return
374
389
  if _orphan_stop.is_set():
375
390
  _orphan_stop = threading.Event()
@@ -506,7 +521,10 @@ def _swap_sink(new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) ->
506
521
  ``sinks/base.py`` requires ``close()`` to tolerate a concurrent ``emit`` (SPEC-028 FR-001).
507
522
  This inherits that contract rather than weakening it.
508
523
 
509
- **Who performs the swap and who owns the old sink's close are two questions.** The first is
524
+ **Who performs the swap, who owns the old sink's close, and who owns the new one when the
525
+ swap is declined are three questions** — the third added by SPEC-035 FR-003 and answered by
526
+ :func:`_adopt_declined_swap`, off ``Worker.swap_sink``'s return value rather than a predicate
527
+ here, because only the worker knows whether it got as far as reassigning. The first is
510
528
  liveness — a retired worker performs nothing, since :meth:`Worker.swap_sink` returns early
511
529
  once shut down, so routing the swap to it loses the handoff entirely. The second is
512
530
  ownership, exactly as in :func:`_close_orphan_sink`: a worker that *holds* ``old`` has either
@@ -559,16 +577,65 @@ def _swap_sink(new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) ->
559
577
  closer = _lifecycle.close_detached(old)
560
578
  if worker is not None:
561
579
  try:
562
- worker.swap_sink(new_sink, timeout)
580
+ adopted = worker.swap_sink(new_sink, timeout)
563
581
  except Exception as exc:
564
582
  _diag.absorbed(
565
583
  "swapping the log sink", exc, "events may still be delivered to the previous sink"
566
584
  )
585
+ return
586
+ if not adopted:
587
+ _adopt_declined_swap(new_sink)
567
588
  return
568
589
  if closer is not None:
569
590
  closer.join(timeout)
570
591
 
571
592
 
593
+ def _adopt_declined_swap(new_sink: Sink) -> None:
594
+ """Takes ownership of a sink a worker refused mid-swap (SPEC-035 FR-003).
595
+
596
+ ``Worker.swap_sink`` re-checks retirement after its first ``flush()`` and returns early once
597
+ ``_shutdown_done`` latched, but :func:`_swap_sink` had already cleared ``_orphan_sink`` on the
598
+ strength of a worker being live a few instructions earlier. The new sink then sat in the
599
+ config, installed nowhere and recorded nowhere: measured, ``config.sink is B`` was ``True``,
600
+ ``B`` was never closed, and ``health()`` read entirely clean — for a sink whose ``close()``
601
+ *is* its delivery, a ``KafkaSink`` flushing its producer, a silently lost buffer.
602
+
603
+ Only the new sink is re-homed. ``Worker.swap_sink`` declines before reassigning anything, so
604
+ whatever the worker held it still holds, and the worker closes it through its own
605
+ ``_close_if_owed``; closing it here would be the double-close :func:`_close_orphan_sink`
606
+ exists to avoid. The one case with no "old" sink at all is a retired worker that already
607
+ holds ``new_sink`` — the entry check tests ``_shutdown_done`` before identity, so it declines
608
+ a swap that was already satisfied — and there the same ownership guard makes the worker
609
+ perform the single close.
610
+
611
+ **A sink already recorded as closed is refused re-arming**, the guard
612
+ :func:`_note_orphan_emit` carries and for its reason. Without it, an orphan log arming
613
+ ``_orphan_sink`` while this thread is inside ``swap_sink``'s first drain, followed by a
614
+ ``shutdown()`` that closes it, lets this re-arm a closed sink for a second ``close()`` at
615
+ exit — reproduced, and ``Sink.close`` promises no idempotency.
616
+
617
+ ``incomplete_swaps`` is deliberately not moved. It counts an unconfirmed *drain*, and this
618
+ swap had no drain to confirm — the worker declined before reassigning anything — so counting
619
+ it would stop telling an operator whether events were misrouted or a close was merely slow.
620
+
621
+ Args:
622
+ new_sink: The sink the worker refused to adopt.
623
+
624
+ Returns:
625
+ None.
626
+
627
+ Raises:
628
+ None.
629
+ """
630
+ global _orphan_sink
631
+ with _worker_lock:
632
+ _offer_orphan_signal(new_sink)
633
+ if new_sink is _orphan_sink or new_sink is _orphan_closed_sink:
634
+ return
635
+ _register_exit_handler()
636
+ _orphan_sink = new_sink
637
+
638
+
572
639
  def _flush_worker(timeout: float | None = 5.0) -> bool:
573
640
  """Drains the process worker without retiring it, backing ``flush()`` (SPEC-013 FR-003).
574
641
 
@@ -226,6 +226,7 @@ class Worker:
226
226
  self._queue: queue.Queue[object] = queue.Queue(maxsize=max_queue)
227
227
  self._stop = threading.Event()
228
228
  self._drain_finished = threading.Event()
229
+ self._drain_settled = threading.Event()
229
230
  self._shutdown_done = False
230
231
  self._sink_closed = False
231
232
  self._lock = threading.Lock()
@@ -351,6 +352,54 @@ class Worker:
351
352
  """
352
353
  return self._shutdown_done
353
354
 
355
+ @property
356
+ def draining(self) -> bool:
357
+ """Whether this worker's own ``_stop`` is still the sink's route to a cut-short backoff.
358
+
359
+ Neither ``retired`` nor a bare ``_worker is not None`` answers this, and the two specs
360
+ that got it wrong disagreed in opposite directions (SPEC-035 FR-001). SPEC-033 requires a
361
+ sink still being written to *after* a shutdown to be handed a **fresh** event, because
362
+ ``_stop`` is set forever and an ``Event`` never clears, so every later backoff would
363
+ collapse to zero. SPEC-035 requires a sink whose drain is **in flight** to keep the event
364
+ that drain is about to wait on, or the shutdown serves a full backoff and expires. Both
365
+ are the same sink held by the same worker; only the moment differs, so the predicate has
366
+ to be the moment.
367
+
368
+ An abandoned drain counts as not draining. The thread is wedged and the shutdown has
369
+ already given up on it (``_close_orphan_sink`` and SPEC-027 FR-004 leave the sink open
370
+ for that reason), so nothing further will cut its backoff — where SPEC-033's tight retry
371
+ loop would go on costing the still-running application every emit.
372
+
373
+ It reads **one** event, ``_drain_settled``, set both where the loop stops and where a
374
+ shutdown gives up on it — not a conjunction of the two facts. A reader that tested them
375
+ separately would be correct only at the instant it looked: :meth:`shutdown`'s idempotent
376
+ path evaluates this and *then* waits, so an abandonment landing in between has to release
377
+ that wait, and only an event already being waited on can do that. Measured before the
378
+ change, with the first caller expiring while the second was inside its wait: a second
379
+ ``shutdown(timeout=20)`` returned after 20.01 s, and with ``timeout=None`` the process
380
+ never exited at all.
381
+
382
+ ``_drain_finished`` is deliberately left alone rather than widened: :meth:`flush` and
383
+ :meth:`shutdown`'s sentinel gate both read it as "the loop stopped reading the queue",
384
+ which an abandoned — and still running — drain has not done. The two events stay
385
+ distinguishable without a third flag, which is why no ``_drain_abandoned`` is kept:
386
+ abandoned is ``_drain_settled`` set with ``_drain_finished`` unset, and the operator-facing
387
+ form of the same fact is ``stopped_reason == "ShutdownTimeout"``.
388
+
389
+ Read without the lock, as ``retired`` is: the event is set once and never cleared, so a
390
+ racing reader sees one of two answers and both are momentarily true.
391
+
392
+ Args:
393
+ None.
394
+
395
+ Returns:
396
+ Whether the drain loop is still running and has not been abandoned.
397
+
398
+ Raises:
399
+ None.
400
+ """
401
+ return not self._drain_settled.is_set()
402
+
354
403
  def health(self) -> Health:
355
404
  """Snapshots the delivery counters (SPEC-017 FR-005, SPEC-019 FR-003).
356
405
 
@@ -476,7 +525,7 @@ class Worker:
476
525
  return False
477
526
  return marker.delivered
478
527
 
479
- def swap_sink(self, new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) -> None:
528
+ def swap_sink(self, new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) -> bool:
480
529
  """Retargets delivery at a new sink, draining and closing the previous one (FR-003).
481
530
 
482
531
  This is what makes a late ``configure(sink=...)`` mean what it says. The sink was
@@ -515,33 +564,41 @@ class Worker:
515
564
  hold ``configure()`` for a multiple of the budget. ``None`` waits indefinitely.
516
565
 
517
566
  Returns:
518
- None. The outcome is reported through ``health().incomplete_swaps`` and one stderr
519
- line rather than a return value, because the caller is ``configure()``, which has
520
- never had one and whose callers do not check.
567
+ Whether this worker now holds ``new_sink``, and therefore owns its close. ``False``
568
+ means **declined**, which happens only when ``_shutdown_done`` latched and the
569
+ caller must then own the handoff itself, because a declined swap leaves the new sink
570
+ installed nowhere (SPEC-035 FR-003). ``True`` covers the sink already being this
571
+ worker's, which owes nothing further. The *quality* of the swap is still reported
572
+ through ``health().incomplete_swaps`` and one stderr line rather than here: an
573
+ unconfirmed drain is a swap that happened, so it returns ``True``, and conflating the
574
+ two would make ``_swap_sink`` re-home a sink this worker is delivering to.
521
575
 
522
576
  Raises:
523
577
  None on a sink fault. A close that fails is announced, as everywhere else
524
578
  (SPEC-025 FR-004).
525
579
  """
526
580
  with self._lock:
527
- if self._shutdown_done or self.sink is new_sink:
528
- return
581
+ if self._shutdown_done:
582
+ return False
583
+ if self.sink is new_sink:
584
+ return True
529
585
  deadline = None if timeout is None else time.monotonic() + timeout
530
586
  drained = self.flush(timeout)
531
587
  with self._lock:
532
588
  if self._shutdown_done:
533
- return
589
+ return False
534
590
  old = self.sink
535
591
  if old is new_sink:
536
- return
592
+ return True
537
593
  self.sink = new_sink
538
594
  self._offer_stop_signal()
539
595
  remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
540
596
  if not (drained and self.flush(remaining)):
541
597
  self._record_incomplete_swap(timeout)
542
- return
598
+ return True
543
599
  left = None if deadline is None else max(0.0, deadline - time.monotonic())
544
600
  self._close_swapped_out(old, left)
601
+ return True
545
602
 
546
603
  def _record_incomplete_swap(self, timeout: float | None) -> None:
547
604
  """Counts a swap whose drain could not be confirmed, then announces it (FR-003).
@@ -663,6 +720,19 @@ class Worker:
663
720
  liveness test would queue a sentinel through that whole window. The flag is set before
664
721
  that call, so this one does not.
665
722
 
723
+ **The idempotent path waits for the drain it found running** (SPEC-035 FR-004).
724
+ ``_shutdown_done`` latches on *entry*, so a second caller used to take that branch and
725
+ return in under a millisecond over a drain that had barely started. The shape is rarely
726
+ two user calls: it is a ``shutdown()`` on one thread and ``atexit`` on the main thread,
727
+ and measured with a 2 s-emit sink and three traced calls it delivered **nothing**, never
728
+ closed the sink, and left the process gone in 0.39 s with nothing on stderr. The
729
+ wait is on ``_drain_settled`` and bounded by *this* call's own budget, so
730
+ ``shutdown(timeout=0)`` still returns promptly and a caller never inherits the other
731
+ call's deadline. It is skipped where :attr:`draining` is already false, and **released**
732
+ where the drain is abandoned after the wait began — the first caller sets the same event
733
+ when its join expires, which is what stops a wedged thread costing the second caller its
734
+ whole budget in front of an exit that has to happen.
735
+
666
736
  :meth:`_release_waiters` runs on the way out for the sibling case the ordering cannot
667
737
  reach: a ``flush()`` that passed its liveness check microseconds before the thread
668
738
  finished can still queue a marker nothing will answer, and with ``timeout=None`` that
@@ -691,6 +761,10 @@ class Worker:
691
761
  first = not self._shutdown_done
692
762
  self._shutdown_done = True
693
763
  if not first:
764
+ if self.draining:
765
+ self._drain_settled.wait(
766
+ None if deadline is None else max(0.0, deadline - time.monotonic())
767
+ )
694
768
  self._close_if_owed()
695
769
  self._join_closers(None if deadline is None else max(0.0, deadline - time.monotonic()))
696
770
  return
@@ -706,6 +780,7 @@ class Worker:
706
780
  with self._lock:
707
781
  if self.stopped_reason is None:
708
782
  self.stopped_reason = "ShutdownTimeout"
783
+ self._drain_settled.set()
709
784
  _diag.lost(
710
785
  "item",
711
786
  queued,
@@ -879,6 +954,7 @@ class Worker:
879
954
  self._drain(pending)
880
955
  finally:
881
956
  self._drain_finished.set()
957
+ self._drain_settled.set()
882
958
  except BaseException as exc:
883
959
  self._terminal_failure(exc, len(pending))
884
960
  finally: