Skip to content

Commit c78d665

Browse files
co-cydwoz
authored andcommitted
Unify error handling in pgjsonb returner and event_return
Two asymmetries existed in the pgjsonb writer functions: `returner(ret)` only caught `salt.exceptions.SaltMasterError`, which `_get_serv` raises only on connection failure (`OperationalError`). A `psycopg2.DatabaseError` raised during the actual `cur.execute(...)` into `salt_returns` (FK violation, NOT NULL violation, deadlock, DataError) propagated to the caller. The publish path in `_prep_pub` catches it via `except Exception -> log.critical`, but the syndic-aggregate path at master.py:1699-1701 does not, so a single bad row could escape into a master subprocess. `event_return(events)` had no error handling at all. The event-bus caller in `EventReturn._flush_event_single` already wraps it in `except Exception -> log.error`, so the master does not crash, but the entire `event_queue` is dropped on any failure with no local context about how many events were lost. Add a `psycopg2.DatabaseError` catch to both functions; log via `log.exception` so the traceback is preserved. Tighten the existing SaltMasterError messages to include jid/id (for returns) or the batch size (for events) so operators can correlate the drop to the lost payload. Behaviour is unchanged on the publish path; the syndic-aggregate path is now properly contained. While here, drop `event_return`'s positional `events` argument to `_get_serv`. The `ret` parameter is the return-dict of one returner call; passing a list of events was a copy-paste leftover from `returner(ret)`. Today `salt.returners.get_returner_options` is permissive enough that the result is the same as `ret=None`, but the line has always been semantically wrong and is misleading to read. Add four behavioural tests: - `test_returner_logs_on_connection_failure_without_raising` - `test_returner_logs_on_database_error_without_raising` - `test_event_return_inserts_each_event_into_salt_events` -- happy-path coverage that did not exist; also pins `_get_serv(commit=True)` (no positional `events`). - `test_event_return_logs_on_database_error_without_raising` Depends on #69049 (which routed the `_get_serv`/`_purge_jobs`/ `_archive_jobs` errors through Salt's logger); this PR builds on that branch and should be merged after it. Refs: #69058
1 parent a2eb633 commit c78d665

3 files changed

Lines changed: 145 additions & 11 deletions

File tree

changelog/69058.fixed.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Fixed `salt.returners.pgjsonb.returner` letting any non-connection
2+
`psycopg2.DatabaseError` propagate to the caller — including the
3+
syndic-aggregate publish path in `salt/master.py` which had no outer
4+
catch — so a single bad row could escape into a master subprocess.
5+
`event_return` had no error handling at all and a database failure
6+
during a flush propagated similarly. Both functions now catch
7+
`SaltMasterError` and `psycopg2.DatabaseError` locally, log a
8+
contextual message (jid/id for returns, batch size for events), and
9+
drop the affected payload. While here, fix `event_return` passing
10+
the events list as the positional `ret` argument to `_get_serv`,
11+
which was a copy-paste leftover from `returner(ret)`.

salt/returners/pgjsonb.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,15 @@ def returner(ret):
301301
)
302302
except salt.exceptions.SaltMasterError:
303303
log.critical(
304-
"Could not store return with pgjsonb returner. PostgreSQL server"
305-
" unavailable."
304+
"pgjsonb: PostgreSQL unavailable, dropping return for jid=%s id=%s",
305+
ret.get("jid"),
306+
ret.get("id"),
307+
)
308+
except psycopg2.DatabaseError:
309+
log.exception(
310+
"pgjsonb: failed to store return for jid=%s id=%s",
311+
ret.get("jid"),
312+
ret.get("id"),
306313
)
307314

308315

@@ -313,15 +320,23 @@ def event_return(events):
313320
Requires that configuration be enabled via 'event_return'
314321
option in master config.
315322
"""
316-
with _get_serv(events, commit=True) as cur:
317-
for event in events:
318-
tag = event.get("tag", "")
319-
data = event.get("data", "")
320-
sql = """INSERT INTO salt_events (tag, data, master_id, alter_time)
321-
VALUES (%s, %s, %s, to_timestamp(%s))"""
322-
cur.execute(
323-
sql, (tag, psycopg2.extras.Json(data), __opts__["id"], time.time())
324-
)
323+
try:
324+
with _get_serv(commit=True) as cur:
325+
for event in events:
326+
tag = event.get("tag", "")
327+
data = event.get("data", "")
328+
sql = """INSERT INTO salt_events (tag, data, master_id, alter_time)
329+
VALUES (%s, %s, %s, to_timestamp(%s))"""
330+
cur.execute(
331+
sql,
332+
(tag, psycopg2.extras.Json(data), __opts__["id"], time.time()),
333+
)
334+
except salt.exceptions.SaltMasterError:
335+
log.critical(
336+
"pgjsonb: PostgreSQL unavailable, dropping %d event(s)", len(events)
337+
)
338+
except psycopg2.DatabaseError:
339+
log.exception("pgjsonb: failed to store %d event(s)", len(events))
325340

326341

327342
def save_load(jid, load, minions=None):

tests/pytests/unit/returners/test_pgjsonb.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import pytest
88

9+
import salt.exceptions
910
import salt.returners.pgjsonb as pgjsonb
1011
from tests.support.mock import MagicMock, call, patch
1112

@@ -209,3 +210,110 @@ def test__get_serv_logs_via_salt_logger_and_reraises_on_yield_error(caplog):
209210

210211
fake_cursor.execute.assert_any_call("ROLLBACK")
211212
assert any("_get_serv" in r.message for r in caplog.records)
213+
214+
215+
@pytest.mark.skipif(not pgjsonb.HAS_PG, reason="psycopg2 not installed")
216+
def test_returner_logs_on_connection_failure_without_raising(caplog):
217+
"""When the database is unreachable, ``returner`` must not propagate
218+
the SaltMasterError. The drop is logged at CRITICAL with jid and id
219+
so operators can correlate it to the lost return."""
220+
serv = MagicMock(side_effect=salt.exceptions.SaltMasterError("pg down"))
221+
with patch.object(pgjsonb, "_get_serv", serv):
222+
with caplog.at_level(logging.CRITICAL, logger="salt.returners.pgjsonb"):
223+
pgjsonb.returner(
224+
{
225+
"fun": "test.ping",
226+
"jid": "20260505000000000001",
227+
"id": "minion-1",
228+
"return": True,
229+
}
230+
)
231+
232+
assert any(
233+
"PostgreSQL unavailable" in r.message
234+
and "20260505000000000001" in r.message
235+
and "minion-1" in r.message
236+
for r in caplog.records
237+
)
238+
239+
240+
@pytest.mark.skipif(not pgjsonb.HAS_PG, reason="psycopg2 not installed")
241+
def test_returner_logs_on_database_error_without_raising(caplog):
242+
"""A DatabaseError during the INSERT into ``salt_returns`` must not
243+
propagate; ``returner`` logs and drops the return so that one bad row
244+
cannot escape into syndic-aggregate paths that lack an outer catch."""
245+
cur = MagicMock()
246+
cur.execute.side_effect = psycopg2.DatabaseError("bad row")
247+
serv = MagicMock()
248+
serv.return_value.__enter__.return_value = cur
249+
250+
with patch.object(pgjsonb, "_get_serv", serv):
251+
with caplog.at_level(logging.ERROR, logger="salt.returners.pgjsonb"):
252+
pgjsonb.returner(
253+
{
254+
"fun": "test.ping",
255+
"jid": "20260505000000000002",
256+
"id": "minion-2",
257+
"return": True,
258+
}
259+
)
260+
261+
assert any(
262+
"failed to store return" in r.message
263+
and "20260505000000000002" in r.message
264+
and "minion-2" in r.message
265+
for r in caplog.records
266+
)
267+
268+
269+
@pytest.mark.skipif(not pgjsonb.HAS_PG, reason="psycopg2 not installed")
270+
def test_event_return_inserts_each_event_into_salt_events():
271+
"""``event_return`` writes one row to ``salt_events`` per queued event,
272+
carrying the event tag and the master id. Also pins that ``_get_serv``
273+
is opened with ``commit=True`` only -- passing the events list as the
274+
positional ``ret`` argument was a copy-paste leftover from
275+
``returner(ret)``."""
276+
events = [
277+
{"tag": "salt/job/1/new", "data": {"jid": "1", "fun": "test.ping"}},
278+
{"tag": "salt/auth", "data": {"id": "minion-1", "act": "accept"}},
279+
]
280+
cur = MagicMock()
281+
serv = MagicMock()
282+
serv.return_value.__enter__.return_value = cur
283+
284+
with patch.object(pgjsonb, "_get_serv", serv):
285+
with patch.dict(pgjsonb.__opts__, {"id": "master-A"}):
286+
with patch("salt.returners.pgjsonb.time.time", return_value=1700000000.0):
287+
pgjsonb.event_return(events)
288+
289+
serv.assert_called_once_with(commit=True)
290+
291+
assert cur.execute.call_count == len(events)
292+
for executed_call, event in zip(cur.execute.call_args_list, events):
293+
sql, params = executed_call.args
294+
assert "INSERT INTO salt_events" in sql
295+
tag, _data_json, master_id, ts = params
296+
assert tag == event["tag"]
297+
assert master_id == "master-A"
298+
assert ts == 1700000000.0
299+
300+
301+
@pytest.mark.skipif(not pgjsonb.HAS_PG, reason="psycopg2 not installed")
302+
def test_event_return_logs_on_database_error_without_raising(caplog):
303+
"""A DatabaseError mid-batch must not propagate out of ``event_return``;
304+
the queue length is logged so operators see how many events were lost."""
305+
events = [{"tag": f"tag-{i}", "data": {}} for i in range(3)]
306+
cur = MagicMock()
307+
cur.execute.side_effect = psycopg2.DatabaseError("bad event")
308+
serv = MagicMock()
309+
serv.return_value.__enter__.return_value = cur
310+
311+
with patch.object(pgjsonb, "_get_serv", serv):
312+
with patch.dict(pgjsonb.__opts__, {"id": "master-A"}):
313+
with caplog.at_level(logging.ERROR, logger="salt.returners.pgjsonb"):
314+
pgjsonb.event_return(events)
315+
316+
assert any(
317+
"failed to store" in r.message and "3 event" in r.message
318+
for r in caplog.records
319+
)

0 commit comments

Comments
 (0)