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.
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/PKG-INFO +1 -1
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/postgres.py +31 -1
- log_foundry-0.10.2.dev50/src/log_foundry/sinks/pubsub.py +465 -0
- log_foundry-0.10.2.dev49/src/log_foundry/sinks/pubsub.py +0 -187
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/LICENSE +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/README.md +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/__init__.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/_diag.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/_lifecycle.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/api.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/config.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/console.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/context.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/decorator.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/model.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/results.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/_retry.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/multi.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/transform.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/util.py +0 -0
- {log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/worker.py +0 -0
|
@@ -154,7 +154,7 @@ class PostgresSink:
|
|
|
154
154
|
self._conn.commit()
|
|
155
155
|
return
|
|
156
156
|
except Exception as err:
|
|
157
|
-
self.
|
|
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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{log_foundry-0.10.2.dev49 → log_foundry-0.10.2.dev50}/src/log_foundry/sinks/elasticsearch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|