Skip to content

Commit b9b3c63

Browse files
authored
Merge pull request #69059 from co-cy/pgjsonb-unify-error-handling
Unify error handling in pgjsonb returner and event_return
2 parents 851f61e + c78d665 commit b9b3c63

4 files changed

Lines changed: 238 additions & 45 deletions

File tree

changelog/69048.fixed.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fixed `salt.returners.pgjsonb` writing database errors to `sys.stderr`
2+
instead of Salt's logger. Errors from `_get_serv`, `_purge_jobs` and
3+
`_archive_jobs` are now reported via `log.exception`, so they reach
4+
the configured `log_file` / syslog destination on a daemonized master,
5+
including a full traceback. The unused `import sys` is also dropped.

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: 53 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,6 @@
161161
"""
162162

163163
import logging
164-
import sys
165164
import time
166165
from contextlib import contextmanager
167166

@@ -264,9 +263,8 @@ def _get_serv(ret=None, commit=False):
264263

265264
try:
266265
yield cursor
267-
except psycopg2.DatabaseError as err:
268-
error = err.args
269-
sys.stderr.write(str(error))
266+
except psycopg2.DatabaseError:
267+
log.exception("pgjsonb: database error inside _get_serv block")
270268
cursor.execute("ROLLBACK")
271269
raise
272270
else:
@@ -303,8 +301,15 @@ def returner(ret):
303301
)
304302
except salt.exceptions.SaltMasterError:
305303
log.critical(
306-
"Could not store return with pgjsonb returner. PostgreSQL server"
307-
" 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"),
308313
)
309314

310315

@@ -315,15 +320,23 @@ def event_return(events):
315320
Requires that configuration be enabled via 'event_return'
316321
option in master config.
317322
"""
318-
with _get_serv(events, commit=True) as cur:
319-
for event in events:
320-
tag = event.get("tag", "")
321-
data = event.get("data", "")
322-
sql = """INSERT INTO salt_events (tag, data, master_id, alter_time)
323-
VALUES (%s, %s, %s, to_timestamp(%s))"""
324-
cur.execute(
325-
sql, (tag, psycopg2.extras.Json(data), __opts__["id"], time.time())
326-
)
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))
327340

328341

329342
def save_load(jid, load, minions=None):
@@ -470,31 +483,28 @@ def _purge_jobs(timestamp):
470483
)
471484
cursor.execute(sql, (timestamp,))
472485
cursor.execute("COMMIT")
473-
except psycopg2.DatabaseError as err:
474-
error = err.args
475-
sys.stderr.write(str(error))
486+
except psycopg2.DatabaseError:
487+
log.exception("pgjsonb: failed to purge jids")
476488
cursor.execute("ROLLBACK")
477-
raise err
489+
raise
478490

479491
try:
480492
sql = "delete from salt_returns where alter_time < %s"
481493
cursor.execute(sql, (timestamp,))
482494
cursor.execute("COMMIT")
483-
except psycopg2.DatabaseError as err:
484-
error = err.args
485-
sys.stderr.write(str(error))
495+
except psycopg2.DatabaseError:
496+
log.exception("pgjsonb: failed to purge salt_returns")
486497
cursor.execute("ROLLBACK")
487-
raise err
498+
raise
488499

489500
try:
490501
sql = "delete from salt_events where alter_time < %s"
491502
cursor.execute(sql, (timestamp,))
492503
cursor.execute("COMMIT")
493-
except psycopg2.DatabaseError as err:
494-
error = err.args
495-
sys.stderr.write(str(error))
504+
except psycopg2.DatabaseError:
505+
log.exception("pgjsonb: failed to purge salt_events")
496506
cursor.execute("ROLLBACK")
497-
raise err
507+
raise
498508

499509
return True
500510

@@ -518,11 +528,12 @@ def _archive_jobs(timestamp):
518528
cursor.execute(sql)
519529
cursor.execute("COMMIT")
520530
target_tables[table_name] = tmp_table_name
521-
except psycopg2.DatabaseError as err:
522-
error = err.args
523-
sys.stderr.write(str(error))
531+
except psycopg2.DatabaseError:
532+
log.exception(
533+
"pgjsonb: failed to create archive table for %s", table_name
534+
)
524535
cursor.execute("ROLLBACK")
525-
raise err
536+
raise
526537

527538
try:
528539
sql = (
@@ -533,13 +544,12 @@ def _archive_jobs(timestamp):
533544
)
534545
cursor.execute(sql, (timestamp,))
535546
cursor.execute("COMMIT")
536-
except psycopg2.DatabaseError as err:
537-
error = err.args
538-
sys.stderr.write(str(error))
547+
except psycopg2.DatabaseError:
548+
log.exception("pgjsonb: failed to archive jids")
539549
cursor.execute("ROLLBACK")
540-
raise err
541-
except Exception as e: # pylint: disable=broad-except
542-
log.error(e)
550+
raise
551+
except Exception: # pylint: disable=broad-except
552+
log.exception("pgjsonb: unexpected error archiving jids")
543553
raise
544554

545555
try:
@@ -548,23 +558,21 @@ def _archive_jobs(timestamp):
548558
)
549559
cursor.execute(sql, (timestamp,))
550560
cursor.execute("COMMIT")
551-
except psycopg2.DatabaseError as err:
552-
error = err.args
553-
sys.stderr.write(str(error))
561+
except psycopg2.DatabaseError:
562+
log.exception("pgjsonb: failed to archive salt_returns")
554563
cursor.execute("ROLLBACK")
555-
raise err
564+
raise
556565

557566
try:
558567
sql = "insert into {} select * from {} where alter_time < %s".format(
559568
target_tables["salt_events"], "salt_events"
560569
)
561570
cursor.execute(sql, (timestamp,))
562571
cursor.execute("COMMIT")
563-
except psycopg2.DatabaseError as err:
564-
error = err.args
565-
sys.stderr.write(str(error))
572+
except psycopg2.DatabaseError:
573+
log.exception("pgjsonb: failed to archive salt_events")
566574
cursor.execute("ROLLBACK")
567-
raise err
575+
raise
568576

569577
return _purge_jobs(timestamp)
570578

tests/pytests/unit/returners/test_pgjsonb.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
Unit tests for the PGJsonb returner (pgjsonb).
33
"""
44

5+
import logging
6+
57
import pytest
68

9+
import salt.exceptions
710
import salt.returners.pgjsonb as pgjsonb
811
from tests.support.mock import MagicMock, call, patch
912

@@ -148,3 +151,169 @@ def test_save_load_uses_plain_insert_on_pre_pg_95():
148151

149152
sql = cur.execute.call_args.args[0]
150153
assert "ON CONFLICT" not in sql
154+
155+
156+
@pytest.mark.skipif(not pgjsonb.HAS_PG, reason="psycopg2 not installed")
157+
def test__purge_jobs_logs_via_salt_logger_and_reraises_on_db_error(caplog):
158+
"""When the DELETE inside ``_purge_jobs`` fails, the error must reach
159+
Salt's logger (not stderr), the transaction is rolled back, and the
160+
original ``DatabaseError`` is re-raised."""
161+
cursor = MagicMock()
162+
cursor.execute.side_effect = [psycopg2.DatabaseError("boom"), None]
163+
serv = MagicMock()
164+
serv.return_value.__enter__.return_value = cursor
165+
166+
with patch.object(pgjsonb, "_get_serv", serv):
167+
with caplog.at_level(logging.ERROR, logger="salt.returners.pgjsonb"):
168+
with pytest.raises(psycopg2.DatabaseError):
169+
pgjsonb._purge_jobs("2026-01-01")
170+
171+
cursor.execute.assert_any_call("ROLLBACK")
172+
assert any("failed to purge jids" in r.message for r in caplog.records)
173+
174+
175+
@pytest.mark.skipif(not pgjsonb.HAS_PG, reason="psycopg2 not installed")
176+
def test__archive_jobs_logs_via_salt_logger_and_reraises_on_db_error(caplog):
177+
"""When the CREATE TABLE inside ``_archive_jobs`` fails, the error
178+
reaches Salt's logger, the transaction is rolled back, and the
179+
original ``DatabaseError`` is re-raised."""
180+
cursor = MagicMock()
181+
cursor.execute.side_effect = [psycopg2.DatabaseError("boom"), None]
182+
serv = MagicMock()
183+
serv.return_value.__enter__.return_value = cursor
184+
185+
with patch.object(pgjsonb, "_get_serv", serv):
186+
with caplog.at_level(logging.ERROR, logger="salt.returners.pgjsonb"):
187+
with pytest.raises(psycopg2.DatabaseError):
188+
pgjsonb._archive_jobs("2026-01-01")
189+
190+
cursor.execute.assert_any_call("ROLLBACK")
191+
assert any("failed to create archive table" in r.message for r in caplog.records)
192+
193+
194+
@pytest.mark.skipif(not pgjsonb.HAS_PG, reason="psycopg2 not installed")
195+
def test__get_serv_logs_via_salt_logger_and_reraises_on_yield_error(caplog):
196+
"""When the caller of ``_get_serv()`` raises a DatabaseError inside
197+
the ``with`` block, ``_get_serv`` must log via Salt's logger,
198+
issue ROLLBACK on the connection, and re-raise."""
199+
fake_conn = MagicMock()
200+
fake_conn.server_version = 90500
201+
fake_cursor = MagicMock()
202+
fake_conn.cursor.return_value = fake_cursor
203+
204+
with patch.object(pgjsonb, "_get_options", return_value={}):
205+
with patch("psycopg2.connect", return_value=fake_conn):
206+
with caplog.at_level(logging.ERROR, logger="salt.returners.pgjsonb"):
207+
with pytest.raises(psycopg2.DatabaseError):
208+
with pgjsonb._get_serv():
209+
raise psycopg2.DatabaseError("boom")
210+
211+
fake_cursor.execute.assert_any_call("ROLLBACK")
212+
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)