log-foundry 0.10.2.dev49__tar.gz → 0.10.2.dev50__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 (58) hide show
  1. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/pyproject.toml +1 -1
  3. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/postgres.py +31 -1
  4. log_foundry-0.10.2.dev50/src/log_foundry/sinks/pubsub.py +465 -0
  5. log_foundry-0.10.2.dev49/src/log_foundry/sinks/pubsub.py +0 -187
  6. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/LICENSE +0 -0
  7. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/README.md +0 -0
  8. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/__init__.py +0 -0
  9. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/_diag.py +0 -0
  10. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/_lifecycle.py +0 -0
  11. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/api.py +0 -0
  12. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/config.py +0 -0
  13. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/console.py +0 -0
  14. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/context.py +0 -0
  15. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/decorator.py +0 -0
  16. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/ids.py +0 -0
  17. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/model.py +0 -0
  18. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/py.typed +0 -0
  19. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/results.py +0 -0
  20. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sanitize.py +0 -0
  21. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/__init__.py +0 -0
  22. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/_batch.py +0 -0
  23. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/_chunk.py +0 -0
  24. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/_retry.py +0 -0
  25. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/_socket.py +0 -0
  26. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/_time.py +0 -0
  27. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/base.py +0 -0
  28. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/callback.py +0 -0
  29. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/clickhouse.py +0 -0
  30. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/datadog.py +0 -0
  31. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/elasticsearch.py +0 -0
  32. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/eventhubs.py +0 -0
  33. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/file.py +0 -0
  34. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/filtering.py +0 -0
  35. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/firehose.py +0 -0
  36. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/honeycomb.py +0 -0
  37. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/http.py +0 -0
  38. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/kafka.py +0 -0
  39. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/kinesis.py +0 -0
  40. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/logging_sink.py +0 -0
  41. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/logstash.py +0 -0
  42. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/loki.py +0 -0
  43. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/mongodb.py +0 -0
  44. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/multi.py +0 -0
  45. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/nats.py +0 -0
  46. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/newrelic.py +0 -0
  47. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/rabbitmq.py +0 -0
  48. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/redis.py +0 -0
  49. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/sentry.py +0 -0
  50. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/sns.py +0 -0
  51. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/splunk.py +0 -0
  52. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/sqlite.py +0 -0
  53. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/sqs.py +0 -0
  54. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/stdout.py +0 -0
  55. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/syslog.py +0 -0
  56. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/transform.py +0 -0
  57. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/util.py +0 -0
  58. {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev49
3
+ Version: 0.10.2.dev50
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.dev49"
23
+ version = "0.10.2.dev50"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -154,7 +154,7 @@ class PostgresSink:
154
154
  self._conn.commit()
155
155
  return
156
156
  except Exception as err:
157
- self._conn.rollback()
157
+ self._rollback()
158
158
  if attempt < self.max_retries:
159
159
  wait(_BACKOFF_BASE * (2**attempt), self.log_foundry_stop_signal)
160
160
  continue
@@ -169,6 +169,36 @@ class PostgresSink:
169
169
  f"PostgresSink inserted none of {len(batch)} event(s)"
170
170
  ) from None
171
171
 
172
+ def _rollback(self) -> None:
173
+ """Discards the failed transaction, absorbing a rollback that itself fails (FR-002).
174
+
175
+ The bare call this replaces was the most common failure compounding itself: when the
176
+ server has closed the session — the usual reason the insert failed — psycopg raises from
177
+ ``rollback()`` too, and that escaped mid-handler. Measured at ``max_retries=3``, attempts
178
+ dropped from 4 to 1, ``losses()`` reported ``failed=0`` after a totally lost batch, no
179
+ ``_diag.lost`` line was written, and the worker received a raw driver exception instead
180
+ of ``SinkDeliveryError``.
181
+
182
+ Absorbing is right rather than merely convenient: the rollback is *cleanup* for a failure
183
+ already being handled, so its own failure must not displace the original one, and a
184
+ connection too broken to roll back is a connection the remaining attempts will fail on
185
+ anyway — visibly, and through the counters.
186
+
187
+ Args:
188
+ None.
189
+
190
+ Returns:
191
+ None.
192
+
193
+ Raises:
194
+ None. ``Exception`` only, never ``BaseException``: a ``KeyboardInterrupt`` here is the
195
+ operator's intent and must reach the caller (SPEC-025).
196
+ """
197
+ try:
198
+ self._conn.rollback()
199
+ except Exception as err:
200
+ _diag.absorbed("PostgresSink.rollback", err)
201
+
172
202
  def close(self) -> None:
173
203
  """Commits pending work and closes only an owned connection (FR-005).
174
204
 
@@ -0,0 +1,465 @@
1
+ """GooglePubSubSink — publish events to a Google Cloud Pub/Sub topic (arch §8, SPEC-010)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import time
8
+ from typing import Any
9
+
10
+ from log_foundry import _diag
11
+ from log_foundry.sinks._retry import wait
12
+ from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
13
+
14
+ __all__ = ["GooglePubSubSink"]
15
+
16
+
17
+ class _Unboundable(Exception):
18
+ """A future whose ``result()`` takes no ``timeout``, so no bounded wait on it is possible.
19
+
20
+ Raised rather than folded into :meth:`GooglePubSubSink._resolve`'s ``False`` because the two
21
+ need different answers: ``False`` means "not settled yet, wait the rest of the slice and try
22
+ again", while this means "trying again can only fail the same way". Conflating them spun the
23
+ slice loop at full speed until the deadline — measured at 3.5 million ``result()`` calls and
24
+ a pegged core for one second, which is thirty at the shipped default.
25
+ """
26
+
27
+ DEFAULT_OVERFLOW_TIMEOUT = 30.0
28
+ """Seconds one ``emit`` waits on an over-bound publish before putting it back (FR-004 AC-2).
29
+
30
+ Thirty matches ``DEFAULT_MAX_RETRY_AFTER``: it is long enough that an ordinary publish settles
31
+ inside it, and short enough that the single drain thread is never held past what
32
+ ``shutdown()``'s own budget allows. It bounds the **whole** overflow pass of one ``emit``, not
33
+ each future in it — per-future it would be thirty seconds times however many futures are over
34
+ the limit, which is not a bound at all.
35
+ """
36
+
37
+ _POLL_INTERVAL = 0.05
38
+ """Longest single wait on one future, so a shutdown is noticed within it (SPEC-027).
39
+
40
+ The deadline bounds the *total*, but a client honouring a 30-second timeout on one future
41
+ blocks for all thirty regardless — measured, a shutdown 0.05 s into an emit went unnoticed for
42
+ 10 s. Waiting in slices makes the stop signal effective within one slice, which is the
43
+ "interruptible" half of SPEC-027's rule; the deadline is the "bounded" half.
44
+ """
45
+
46
+ DEFAULT_MAX_PENDING = 1000
47
+ """Outstanding publish futures one sink will hold before it waits (FR-004 AC-2).
48
+
49
+ Roughly one drain interval's worth at the default batch size: large enough that ordinary
50
+ publishing never serializes on it, and small enough to bound memory at a constant rather than at
51
+ the number of events the process has ever logged.
52
+ """
53
+
54
+
55
+ class GooglePubSubSink:
56
+ """A :class:`~log_foundry.sinks.base.Sink` that publishes events to a Pub/Sub topic.
57
+
58
+ This is a durable-buffer sink on ``google-cloud-pubsub``, the optional ``gcp-pubsub`` extra,
59
+ imported lazily. ``publish()`` returns a future that resolves asynchronously, so the sink
60
+ accumulates the batch's futures and resolves them on :meth:`close`.
61
+
62
+ The driver requirement satisfied (SPEC-028 FR-002): this sink takes **no** transport lock —
63
+ the publisher client owns its own batching and threading, and ``publish()`` is a local
64
+ hand-off. What it does hold is the pending-futures list, which is genuinely shared between
65
+ ``emit`` (appending) and ``close`` (resolving), so that list has its own small lock and
66
+ ``close`` swaps it out rather than iterating and clearing. Without the swap, a future
67
+ appended after the loop passed its index was dropped unresolved: an unconfirmed publish
68
+ never counted in ``failed`` and never reported by :meth:`losses`, which is precisely the
69
+ silent loss SPEC-026 exists to end.
70
+
71
+ That same loss was reachable from *outside* ``close`` until SPEC-032 FR-001: nothing stopped
72
+ a later ``emit`` appending to the fresh list, and nothing would ever call ``result()`` on
73
+ it. The sink now refuses a batch once closed, and the append re-checks under the futures
74
+ lock, so a publish cannot land on a list the swap has already taken.
75
+
76
+ The list was also append-only for the life of the process until SPEC-038 FR-004: memory grew
77
+ with the total number of events logged, and because ``result()`` ran only at ``close``,
78
+ ``failed`` stayed at zero and ``health()`` reported clean through an entire Pub/Sub outage.
79
+ Each ``emit`` now reaps whatever has settled and holds at most ``max_pending`` outstanding
80
+ **between emits, while the client is resolving**. Two documented exceptions: the reap runs
81
+ once at the end of a call, so one ``emit`` of 6,000 events peaks at 5,999 futures before it
82
+ trims; and against a client resolving nothing at all the list grows by a batch per emit,
83
+ because :meth:`_await_overflow` puts back what its bounded wait did not outlast rather than
84
+ discarding it. What the bound rules out is growth with the total number of events a *healthy*
85
+ process has logged, which is what FR-004 is about.
86
+
87
+ Attributes:
88
+ max_pending: Outstanding futures held between emits before ``emit`` waits on the oldest.
89
+ overflow_timeout: Seconds one over-bound publish is waited on before being put back.
90
+ failed: Publishes the client did not confirm.
91
+ rejected: Publishes the client refused outright, before any future existed.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ topic: str,
97
+ *,
98
+ client: Any = None,
99
+ max_pending: int | None = None,
100
+ overflow_timeout: float = DEFAULT_OVERFLOW_TIMEOUT,
101
+ ) -> None:
102
+ """Binds the sink to a topic.
103
+
104
+ Args:
105
+ topic: The topic to publish to.
106
+ client: A publisher client to borrow, or ``None`` to build one.
107
+ max_pending: Outstanding futures held before :meth:`emit` waits on the oldest, or
108
+ ``None`` for :data:`DEFAULT_MAX_PENDING`. Floored at one, since a bound of zero would
109
+ make every publish synchronous and defeat the client's own batching.
110
+ overflow_timeout: Seconds to wait on one over-bound publish before putting it back.
111
+ The wait runs on the worker's single drain thread, so it is bounded by SPEC-027's
112
+ rule that no sink wait may be unbounded or uninterruptible.
113
+
114
+ Returns:
115
+ None.
116
+
117
+ Raises:
118
+ ImportError: If the ``gcp-pubsub`` extra is not installed.
119
+ """
120
+ if client is None:
121
+ from google.cloud import pubsub_v1 # type: ignore[import-not-found]
122
+
123
+ client = pubsub_v1.PublisherClient()
124
+ self.topic = topic
125
+ self.client = client
126
+ self.max_pending = max(max_pending if max_pending is not None else DEFAULT_MAX_PENDING, 1)
127
+ self.overflow_timeout = overflow_timeout
128
+ self.log_foundry_stop_signal: threading.Event | None = None
129
+ self.failed = 0
130
+ self.rejected = 0
131
+ self._counter_lock = threading.Lock()
132
+ self._futures_lock = threading.Lock()
133
+ self._futures: list[Any] = []
134
+ self._closed = False
135
+
136
+ def losses(self) -> SinkLosses:
137
+ """Reports refused publishes and futures that resolved to an error (FR-002).
138
+
139
+ Args:
140
+ None.
141
+
142
+ Returns:
143
+ The counters. ``failed`` moves as futures settle, which :meth:`emit` reaps on every
144
+ call (FR-004), so an outage is visible while it is happening rather than only at
145
+ shutdown. It still lags the publish that caused it by however long the client takes to
146
+ give up, which is a property of the client's asynchronous publish and not of this
147
+ accessor.
148
+
149
+ Raises:
150
+ None.
151
+ """
152
+ with self._counter_lock:
153
+ return SinkLosses(dropped=self.rejected, failed=self.failed)
154
+
155
+ def emit(self, batch: list[dict[str, object]]) -> None:
156
+ """Publishes one message per event, then reaps the futures that have settled (FR-008).
157
+
158
+ ``publish()`` is a local hand-off returning a future, so a refusal is the only failure
159
+ this call can observe. It is isolated per event, because letting the first refusal
160
+ propagate would hand the worker a batch whose earlier events are already in flight, and
161
+ the retry would duplicate them. The stderr line names which counter moved, since
162
+ "refused" and "unconfirmed" mean different things.
163
+
164
+ A closed sink refuses the batch before touching the client (SPEC-032 FR-001), because
165
+ nothing will resolve a future appended after :meth:`close` swapped the list out.
166
+ Refusing moves no counter here: it is a failure reported to the worker, which records it
167
+ in ``health().failed_batches``, not loss this sink absorbed. The flag is read twice on
168
+ purpose — once for the batch, and again under ``_futures_lock`` for each append, which
169
+ is the lock ``close`` swaps under. Without the second read a close landing mid-loop
170
+ would leave exactly the orphaned future this guard exists to prevent, and the read costs
171
+ nothing because the append already takes that lock. Only the read happens under it: the
172
+ counter bump and the stderr line are taken *after* the lock is released, because
173
+ ``close`` waits on the same lock and a blocked stderr would otherwise hold it — an I/O
174
+ call inside a transport lock is what SPEC-028's two-lock decision exists to avoid.
175
+
176
+ A close that lands mid-batch does **not** raise, even when it catches every event. Each
177
+ one had ``publish()`` called on it and may well land, so raising would have the worker
178
+ re-send them and duplicate whatever did — the SPEC-018 rule that only a *provable*
179
+ non-delivery may be retried. They are counted as unconfirmed instead, which is exactly
180
+ what they are. That is why the total-failure raise below tests the refusals rather than
181
+ the successes: "nothing was published" and "nothing was confirmed" are different claims,
182
+ and only the first is safe to retry.
183
+
184
+ The reap runs *before* the total-failure test, so a batch the client refused outright
185
+ still returns the pending list to its bound on the way out.
186
+
187
+ Args:
188
+ batch: The events to publish.
189
+
190
+ Returns:
191
+ None.
192
+
193
+ Raises:
194
+ SinkDeliveryError: When the sink was already closed on entry, or when every event was
195
+ refused (SPEC-026 FR-001).
196
+ """
197
+ if not batch:
198
+ return
199
+ if self._closed:
200
+ raise SinkDeliveryError(
201
+ f"GooglePubSubSink published none of {len(batch)} event(s): the sink is closed"
202
+ )
203
+ refused = 0
204
+ for event in batch:
205
+ try:
206
+ future = self.client.publish(self.topic, data=json.dumps(event).encode("utf-8"))
207
+ except Exception as err:
208
+ refused += 1
209
+ with self._counter_lock:
210
+ self.rejected += 1
211
+ _diag.lost("event", 1, f"GooglePubSubSink refused the publish, {type(err).__name__}")
212
+ continue
213
+ with self._futures_lock:
214
+ orphaned = self._closed
215
+ if not orphaned:
216
+ self._futures.append(future)
217
+ if orphaned:
218
+ with self._counter_lock:
219
+ self.failed += 1
220
+ _diag.lost(
221
+ "event", 1, "GooglePubSubSink publish unconfirmed, the sink closed mid-batch"
222
+ )
223
+ self._reap()
224
+ if refused == len(batch):
225
+ raise SinkDeliveryError(
226
+ f"GooglePubSubSink published none of {len(batch)} event(s)"
227
+ )
228
+
229
+ def _reap(self) -> None:
230
+ """Resolves the futures that have settled, and waits on the oldest if over the bound.
231
+
232
+ This is what makes an outage visible while it is happening (FR-004 AC-1) and what stops
233
+ the pending list growing with the total number of events the process has ever logged
234
+ (AC-4). Before it, ``result()`` was called only from :meth:`close`, so ``failed`` stayed
235
+ at zero and ``health()`` read clean through an entire Pub/Sub outage.
236
+
237
+ Futures are selected under the futures lock and resolved *outside* it, as :meth:`close`
238
+ does: a settled future's ``result()`` is immediate, but an overflow future's is a wait,
239
+ and holding the lock across it would block every concurrent publish behind it — the I/O
240
+ inside a transport lock that SPEC-028's two-lock arrangement exists to avoid.
241
+
242
+ The partition is **one pass**, and that is load-bearing rather than tidy. Two
243
+ comprehensions query ``done()`` twice per future, and a future that settles between the
244
+ two queries — which is what a client's commit thread does — lands in neither list: not
245
+ resolved, and dropped by the reassignment below. Measured, five failed publishes vanished
246
+ with ``losses()`` reading clean, and under a real thread race 41% of failures went
247
+ uncounted. That is the silent loss this method exists to end, so the scan may not ask the
248
+ same future twice.
249
+
250
+ Overflow is taken from the front, so the oldest publish is the one waited on. Waiting
251
+ rather than discarding is deliberate (AC-2): the event has already been handed to the
252
+ client and may yet be delivered, so dropping it here would invent a loss that the
253
+ destination has not committed.
254
+
255
+ Args:
256
+ None.
257
+
258
+ Returns:
259
+ None.
260
+
261
+ Raises:
262
+ None.
263
+ """
264
+ with self._futures_lock:
265
+ settled: list[Any] = []
266
+ outstanding: list[Any] = []
267
+ for future in self._futures:
268
+ (settled if _has_settled(future) else outstanding).append(future)
269
+ excess = max(len(outstanding) - self.max_pending, 0)
270
+ overflow, self._futures = outstanding[:excess], outstanding[excess:]
271
+ for future in settled:
272
+ self._resolve(future)
273
+ self._await_overflow(overflow)
274
+
275
+ def _await_overflow(self, overflow: list[Any]) -> None:
276
+ """Waits on the oldest futures once the pending list is over its bound (FR-004 AC-2).
277
+
278
+ The wait is **bounded and interruptible**, per SPEC-027. This runs inside ``emit`` on the
279
+ worker's single drain thread, so an unbounded ``result()`` here is a pause on all log
280
+ delivery that ``shutdown()`` cannot cut short. Before FR-004 the only blocking
281
+ ``result()`` was in ``close``, where such a wait belongs.
282
+
283
+ **One deadline covers the whole list, and the stop signal is re-read each time round.**
284
+ A per-*future* timeout is not a bound: with the shipped 30 s and ten futures over the
285
+ limit, one ``emit`` blocked for five minutes, and a shutdown landing mid-loop was not
286
+ noticed until every future had been waited on — measured at 5.04 s for a signal set after
287
+ 0.3 s. Both were regressions against a version of this sink that never blocked at all.
288
+
289
+ A future the wait does not outlast is **put back**, not counted: it may still land, and
290
+ ``close`` resolves whatever remains. That has a consequence worth stating rather than
291
+ hiding, because it is a real limit: against a client that resolves *nothing*, the pending
292
+ list grows by a batch per emit (measured 10 → 60 over six emits) and the ``max_pending``
293
+ bound stops holding. Nothing better is available — the three properties "never invent
294
+ loss", "never block delivery indefinitely" and "bound memory" cannot all hold when the
295
+ destination has stopped answering, and the first two are the ones this library is for.
296
+ The growth is a symptom of the outage, every event in it is still accounted for, and
297
+ ``losses()`` reports what resolves.
298
+
299
+ Args:
300
+ overflow: The oldest outstanding futures, already removed from the pending list.
301
+
302
+ Returns:
303
+ None.
304
+
305
+ Raises:
306
+ None.
307
+ """
308
+ if not overflow:
309
+ return
310
+ deadline = time.monotonic() + self.overflow_timeout
311
+ unresolved: list[Any] = []
312
+ for index, future in enumerate(overflow):
313
+ settled = False
314
+ while not self._out_of_time(deadline):
315
+ began = time.monotonic()
316
+ slice_ = min(deadline - began, _POLL_INTERVAL)
317
+ try:
318
+ settled = self._resolve(future, slice_)
319
+ except _Unboundable:
320
+ break
321
+ if settled:
322
+ break
323
+ wait(slice_ - (time.monotonic() - began), self.log_foundry_stop_signal)
324
+ if settled:
325
+ continue
326
+ unresolved.append(future)
327
+ if self._out_of_time(deadline):
328
+ unresolved.extend(overflow[index + 1 :])
329
+ break
330
+ if not unresolved:
331
+ return
332
+ with self._futures_lock:
333
+ if not self._closed:
334
+ self._futures[:0] = unresolved
335
+ return
336
+ for future in unresolved:
337
+ self._resolve(future)
338
+
339
+ def _out_of_time(self, deadline: float) -> bool:
340
+ """Reports whether the overflow pass must stop, on the deadline or on a shutdown.
341
+
342
+ The stop signal is read here rather than once before the loop: read once, a shutdown
343
+ arriving mid-pass was not noticed until every future had been waited on.
344
+
345
+ Args:
346
+ deadline: The monotonic time the pass may not run past.
347
+
348
+ Returns:
349
+ True when no further waiting may happen.
350
+
351
+ Raises:
352
+ None.
353
+ """
354
+ if time.monotonic() >= deadline:
355
+ return True
356
+ stop = self.log_foundry_stop_signal
357
+ return stop is not None and stop.is_set()
358
+
359
+ def _resolve(self, future: Any, timeout: float | None = None) -> bool:
360
+ """Waits for one publish to settle, counting and announcing a failure.
361
+
362
+ Args:
363
+ future: The publish future to resolve.
364
+ timeout: Seconds to wait, or ``None`` to wait indefinitely — which is what ``close``
365
+ does, and what a shutdown defers this to.
366
+
367
+ Returns:
368
+ True when the future settled, False when a *bounded* wait expired with it still in
369
+ flight. An expired wait is not a failure and moves no counter: the publish is
370
+ unfinished, not unconfirmed.
371
+
372
+ A ``TypeError`` from a *bounded* call is read the same way, because a future whose
373
+ ``result()`` takes no ``timeout`` cannot be waited on within one: counting it would
374
+ invent loss on a publish that was going to succeed, which an injected ``client=`` makes
375
+ reachable (measured: three of four healthy publishes reported ``failed`` with a
376
+ ``TypeError`` line). It is put back and resolved unbounded at ``close`` instead. A
377
+ genuine ``TypeError`` from the publish itself takes the same route and is counted
378
+ there, so nothing is lost either way.
379
+
380
+ A ``TimeoutError`` is only read that way when this call set a timeout. With none set —
381
+ which is every call from ``close`` — it is the client reporting that the publish itself
382
+ timed out, and is counted like any other failure. Conflating the two made a genuinely
383
+ unconfirmed publish silently uncounted, which an existing ``losses()`` test caught. A
384
+ server-side timeout arriving *during* a bounded wait is read as "still in flight" and
385
+ the future is put back, so it is counted at ``close`` rather than here — later, never
386
+ lost.
387
+
388
+ Raises:
389
+ _Unboundable: When a bounded call cannot be made at all, which only
390
+ :meth:`_await_overflow` catches. An unresolved future must never crash the worker
391
+ (FR-011), and this is called from ``close`` as well as from the emitting thread, so
392
+ nothing else escapes.
393
+ """
394
+ try:
395
+ future.result() if timeout is None else future.result(timeout=timeout)
396
+ except Exception as err:
397
+ if timeout is not None and isinstance(err, TypeError):
398
+ raise _Unboundable from None
399
+ if timeout is not None and isinstance(err, TimeoutError):
400
+ return False
401
+ with self._counter_lock:
402
+ self.failed += 1
403
+ _diag.lost("event", 1, f"GooglePubSubSink publish unconfirmed, {type(err).__name__}")
404
+ return True
405
+
406
+ def close(self) -> None:
407
+ """Resolves all pending publish futures, counting and logging errors (FR-008).
408
+
409
+ Idempotent. The pending list is swapped out under a lock rather than iterated and then
410
+ cleared (SPEC-028 FR-002): ``emit`` appends to it from any thread, so the old
411
+ iterate-then-``clear()`` discarded any future appended after the loop passed its index —
412
+ an unconfirmed publish whose ``result()`` was never called, never counted in ``failed``
413
+ and never reported by ``losses()``. That is the silent loss SPEC-026 exists to end,
414
+ reached through the one piece of shared state this sink has.
415
+
416
+ The closed flag is set in the same critical section as the swap (SPEC-032 FR-001), which
417
+ is what makes :meth:`emit`'s second check exact: an append winning the lock before this
418
+ point is resolved by the loop below, and one arriving after sees the flag. The futures
419
+ are resolved outside the lock, so a slow ``result()`` never blocks an emit that is about
420
+ to be refused anyway.
421
+
422
+ Args:
423
+ None.
424
+
425
+ Returns:
426
+ None.
427
+
428
+ Raises:
429
+ None. This is an isolation boundary: an unresolved future must never crash the worker
430
+ (FR-011).
431
+ """
432
+ with self._futures_lock:
433
+ self._closed = True
434
+ pending, self._futures = self._futures, []
435
+ for future in pending:
436
+ self._resolve(future)
437
+
438
+
439
+ def _has_settled(future: Any) -> bool:
440
+ """Reports whether a publish future has already resolved, without ever waiting on it.
441
+
442
+ Total, and the two "cannot tell" cases are answered differently on purpose. A future with no
443
+ ``done()`` at all is reported **outstanding**: it cannot be polled, but it is still held
444
+ under ``max_pending``, so it is bounded without every publish being made synchronous — which
445
+ is what reporting it settled would do, since the reap would then call ``result()`` on it
446
+ immediately. A ``done()`` that *raises* is reported settled instead, because a future whose
447
+ own state query fails is broken, and resolving it now counts it rather than leaving it to
448
+ occupy the bound forever.
449
+
450
+ Args:
451
+ future: The publish future to inspect.
452
+
453
+ Returns:
454
+ True when the future has resolved, or when its state query failed.
455
+
456
+ Raises:
457
+ None.
458
+ """
459
+ done = getattr(future, "done", None)
460
+ if not callable(done):
461
+ return False
462
+ try:
463
+ return bool(done())
464
+ except Exception:
465
+ return True
@@ -1,187 +0,0 @@
1
- """GooglePubSubSink — publish events to a Google Cloud Pub/Sub topic (arch §8, SPEC-010)."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import threading
7
- from typing import Any
8
-
9
- from log_foundry import _diag
10
- from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
11
-
12
- __all__ = ["GooglePubSubSink"]
13
-
14
-
15
- class GooglePubSubSink:
16
- """A :class:`~log_foundry.sinks.base.Sink` that publishes events to a Pub/Sub topic.
17
-
18
- This is a durable-buffer sink on ``google-cloud-pubsub``, the optional ``gcp-pubsub`` extra,
19
- imported lazily. ``publish()`` returns a future that resolves asynchronously, so the sink
20
- accumulates the batch's futures and resolves them on :meth:`close`.
21
-
22
- The driver requirement satisfied (SPEC-028 FR-002): this sink takes **no** transport lock —
23
- the publisher client owns its own batching and threading, and ``publish()`` is a local
24
- hand-off. What it does hold is the pending-futures list, which is genuinely shared between
25
- ``emit`` (appending) and ``close`` (resolving), so that list has its own small lock and
26
- ``close`` swaps it out rather than iterating and clearing. Without the swap, a future
27
- appended after the loop passed its index was dropped unresolved: an unconfirmed publish
28
- never counted in ``failed`` and never reported by :meth:`losses`, which is precisely the
29
- silent loss SPEC-026 exists to end.
30
-
31
- That same loss was reachable from *outside* ``close`` until SPEC-032 FR-001: nothing stopped
32
- a later ``emit`` appending to the fresh list, and nothing would ever call ``result()`` on
33
- it. The sink now refuses a batch once closed, and the append re-checks under the futures
34
- lock, so a publish cannot land on a list the swap has already taken.
35
- """
36
-
37
- def __init__(self, topic: str, *, client: Any = None) -> None:
38
- """Binds the sink to a topic.
39
-
40
- Args:
41
- topic: The topic to publish to.
42
- client: A publisher client to borrow, or ``None`` to build one.
43
-
44
- Returns:
45
- None.
46
-
47
- Raises:
48
- ImportError: If the ``gcp-pubsub`` extra is not installed.
49
- """
50
- if client is None:
51
- from google.cloud import pubsub_v1 # type: ignore[import-not-found]
52
-
53
- client = pubsub_v1.PublisherClient()
54
- self.topic = topic
55
- self.client = client
56
- self.failed = 0
57
- self.rejected = 0
58
- self._counter_lock = threading.Lock()
59
- self._futures_lock = threading.Lock()
60
- self._futures: list[Any] = []
61
- self._closed = False
62
-
63
- def losses(self) -> SinkLosses:
64
- """Reports refused publishes and futures that resolved to an error (FR-002).
65
-
66
- Args:
67
- None.
68
-
69
- Returns:
70
- The counters. ``failed`` only moves when the futures are resolved, which happens in
71
- :meth:`close`, so a long-lived process reads zero there until it shuts down — a
72
- property of the client's asynchronous publish, not of this accessor.
73
-
74
- Raises:
75
- None.
76
- """
77
- with self._counter_lock:
78
- return SinkLosses(dropped=self.rejected, failed=self.failed)
79
-
80
- def emit(self, batch: list[dict[str, object]]) -> None:
81
- """Publishes one message per event, retaining each future for flush on close (FR-008).
82
-
83
- ``publish()`` is a local hand-off returning a future, so a refusal is the only failure
84
- this call can observe. It is isolated per event, because letting the first refusal
85
- propagate would hand the worker a batch whose earlier events are already in flight, and
86
- the retry would duplicate them. The stderr line names which counter moved, since
87
- "refused" and "unconfirmed" mean different things.
88
-
89
- A closed sink refuses the batch before touching the client (SPEC-032 FR-001), because
90
- nothing will resolve a future appended after :meth:`close` swapped the list out.
91
- Refusing moves no counter here: it is a failure reported to the worker, which records it
92
- in ``health().failed_batches``, not loss this sink absorbed. The flag is read twice on
93
- purpose — once for the batch, and again under ``_futures_lock`` for each append, which
94
- is the lock ``close`` swaps under. Without the second read a close landing mid-loop
95
- would leave exactly the orphaned future this guard exists to prevent, and the read costs
96
- nothing because the append already takes that lock. Only the read happens under it: the
97
- counter bump and the stderr line are taken *after* the lock is released, because
98
- ``close`` waits on the same lock and a blocked stderr would otherwise hold it — an I/O
99
- call inside a transport lock is what SPEC-028's two-lock decision exists to avoid.
100
-
101
- A close that lands mid-batch does **not** raise, even when it catches every event. Each
102
- one had ``publish()`` called on it and may well land, so raising would have the worker
103
- re-send them and duplicate whatever did — the SPEC-018 rule that only a *provable*
104
- non-delivery may be retried. They are counted as unconfirmed instead, which is exactly
105
- what they are. That is why the total-failure raise below tests the refusals rather than
106
- the successes: "nothing was published" and "nothing was confirmed" are different claims,
107
- and only the first is safe to retry.
108
-
109
- Args:
110
- batch: The events to publish.
111
-
112
- Returns:
113
- None.
114
-
115
- Raises:
116
- SinkDeliveryError: When the sink was already closed on entry, or when every event was
117
- refused (SPEC-026 FR-001).
118
- """
119
- if not batch:
120
- return
121
- if self._closed:
122
- raise SinkDeliveryError(
123
- f"GooglePubSubSink published none of {len(batch)} event(s): the sink is closed"
124
- )
125
- refused = 0
126
- for event in batch:
127
- try:
128
- future = self.client.publish(self.topic, data=json.dumps(event).encode("utf-8"))
129
- except Exception as err:
130
- refused += 1
131
- with self._counter_lock:
132
- self.rejected += 1
133
- _diag.lost("event", 1, f"GooglePubSubSink refused the publish, {type(err).__name__}")
134
- continue
135
- with self._futures_lock:
136
- orphaned = self._closed
137
- if not orphaned:
138
- self._futures.append(future)
139
- if orphaned:
140
- with self._counter_lock:
141
- self.failed += 1
142
- _diag.lost(
143
- "event", 1, "GooglePubSubSink publish unconfirmed, the sink closed mid-batch"
144
- )
145
- if refused == len(batch):
146
- raise SinkDeliveryError(
147
- f"GooglePubSubSink published none of {len(batch)} event(s)"
148
- )
149
-
150
- def close(self) -> None:
151
- """Resolves all pending publish futures, counting and logging errors (FR-008).
152
-
153
- Idempotent. The pending list is swapped out under a lock rather than iterated and then
154
- cleared (SPEC-028 FR-002): ``emit`` appends to it from any thread, so the old
155
- iterate-then-``clear()`` discarded any future appended after the loop passed its index —
156
- an unconfirmed publish whose ``result()`` was never called, never counted in ``failed``
157
- and never reported by ``losses()``. That is the silent loss SPEC-026 exists to end,
158
- reached through the one piece of shared state this sink has.
159
-
160
- The closed flag is set in the same critical section as the swap (SPEC-032 FR-001), which
161
- is what makes :meth:`emit`'s second check exact: an append winning the lock before this
162
- point is resolved by the loop below, and one arriving after sees the flag. The futures
163
- are resolved outside the lock, so a slow ``result()`` never blocks an emit that is about
164
- to be refused anyway.
165
-
166
- Args:
167
- None.
168
-
169
- Returns:
170
- None.
171
-
172
- Raises:
173
- None. This is an isolation boundary: an unresolved future must never crash the worker
174
- (FR-011).
175
- """
176
- with self._futures_lock:
177
- self._closed = True
178
- pending, self._futures = self._futures, []
179
- for future in pending:
180
- try:
181
- future.result()
182
- except Exception as err:
183
- with self._counter_lock:
184
- self.failed += 1
185
- _diag.lost(
186
- "event", 1, f"GooglePubSubSink publish unconfirmed, {type(err).__name__}"
187
- )