Warning, file /panda-server/pandaserver/asyncprocess/tests/db_read_handlers_tests.py was not indexed
or was modified since last indexation (in which case cross-reference links may be missing, inaccurate or erroneous).
0001
0002
0003 import datetime
0004 import decimal
0005 import json
0006 import time
0007 import unittest
0008 from collections.abc import Callable
0009 from types import SimpleNamespace
0010 from typing import Any
0011 from unittest import mock
0012
0013 from pandaserver.asyncprocess import db_read_handlers
0014 from pandaserver.asyncprocess.db_read_handlers import _Batch, batch_deadline, encode_value
0015 from pandaserver.asyncprocess.tests.db_read_tests import INFOS
0016 from pandaserver.config import daemon_config, panda_config
0017 from pandaserver.srvcore.exceptions import DBReaderUnavailableError
0018
0019 AS_OF = datetime.datetime(2026, 10, 12, 12, 0, 0)
0020 D0 = datetime.datetime(2026, 10, 1)
0021
0022 Answer = Callable[[str, dict[str, Any]], list[tuple[Any, ...]]]
0023
0024
0025 class FakeCursor:
0026 def __init__(self, answer: Answer) -> None:
0027 self.answer = answer
0028 self.executed: list[tuple[str, dict[str, Any]]] = []
0029 self.arraysize = 0
0030 self.rows: list[tuple[Any, ...]] = []
0031
0032 def execute(self, sql: str, binds: dict[str, Any] | None = None) -> None:
0033 self.executed.append((sql, dict(binds or {})))
0034 self.rows = self.answer(sql, binds or {})
0035
0036 def fetchall(self) -> list[tuple[Any, ...]]:
0037 return self.rows
0038
0039
0040 class FakeProxy:
0041
0042 backend = "oracle"
0043
0044 def __init__(self, answer: Answer) -> None:
0045 self.call_timeout_ms = 30000
0046 self.cur = FakeCursor(answer)
0047 self.conn = mock.Mock()
0048 self.timeouts: list[int | None] = []
0049 self.closed = False
0050
0051 def set_call_timeout(self, timeout_ms: int | None) -> None:
0052 self.timeouts.append(timeout_ms)
0053
0054 def get_db_utc_now(self) -> datetime.datetime:
0055 return AS_OF
0056
0057 def cleanup(self) -> None:
0058 self.closed = True
0059
0060
0061 TABLE_ROWS: dict[str, list[tuple[Any, ...]]] = {
0062 "jobsActive4": [(1, 10, D0), (2, 10, D0 + datetime.timedelta(days=1)), (3, 11, D0)],
0063 "JEDI_Tasks": [(10, "running"), (11, "done")],
0064 }
0065
0066
0067 def table_answer(sql: str, binds: dict[str, Any]) -> list[tuple[Any, ...]]:
0068
0069 for table, rows in TABLE_ROWS.items():
0070 if f".{table} " in sql:
0071 return rows
0072 return []
0073
0074
0075 Q_JOBS = {"tables": ["jobsActive4"], "where": [["pandaid", "in", [1, 2, 3]]], "columns": ["pandaid", "jeditaskid", "modificationtime"]}
0076 Q_TASKS = {"tables": ["JEDI_Tasks"], "where": [["jeditaskid", "in", {"from_query": 0, "column": "jeditaskid"}]], "columns": ["jeditaskid", "status"]}
0077
0078
0079 class BatchTestBase(unittest.TestCase):
0080 def setUp(self) -> None:
0081 patch = mock.patch.object(db_read_handlers, "get_table_info", side_effect=lambda run, backend, owner, table: INFOS[table])
0082 patch.start()
0083 self.addCleanup(patch.stop)
0084
0085 def run_batch(
0086 self, queries: list[Any], answer: Answer = table_answer, seconds: float = 300, **batch_attrs: Any
0087 ) -> tuple[dict[str, Any], FakeProxy, _Batch]:
0088 proxy = FakeProxy(answer)
0089 batch = _Batch(proxy, time.monotonic() + seconds, 1000, mock.Mock())
0090 for name, value in batch_attrs.items():
0091 setattr(batch, name, value)
0092 return batch.run(queries), proxy, batch
0093
0094
0095 class TestBatch(BatchTestBase):
0096 def test_from_query(self) -> None:
0097 data, proxy, _ = self.run_batch([Q_JOBS, Q_TASKS])
0098 self.assertEqual(data["as_of"], {"__datetime__": "2026-10-12T12:00:00"})
0099 jobs, tasks = data["answers"]
0100 self.assertEqual((jobs["status"], jobs["truncated"]), ("done", False))
0101 self.assertEqual(jobs["tables"][0]["rows"][1], {"pandaid": 2, "jeditaskid": 10, "modificationtime": {"__datetime__": "2026-10-02T00:00:00"}})
0102 self.assertEqual((jobs["tables"][0]["probes"], jobs["tables"][0]["table"]), (3, "jobsActive4"))
0103 self.assertEqual(tasks["tables"][0]["rows"], [{"jeditaskid": 10, "status": "running"}, {"jeditaskid": 11, "status": "done"}])
0104
0105 self.assertTrue(proxy.cur.executed[0][0].startswith("SET TRANSACTION READ ONLY"))
0106 self.assertEqual(proxy.cur.executed[-1][1], {":r0": 10, ":r1": 11})
0107
0108
0109 self.assertEqual(len(proxy.timeouts), 3)
0110 self.assertTrue(all(timeout is not None and 0 < timeout <= 300000 for timeout in proxy.timeouts))
0111
0112 self.assertEqual(proxy.conn.rollback.call_count, 2)
0113
0114 def test_truncation_reaches_what_takes_values_from_it(self) -> None:
0115 data, _, _ = self.run_batch([dict(Q_JOBS, limit=2), Q_TASKS])
0116 jobs, tasks = data["answers"]
0117 self.assertEqual((jobs["truncated"], jobs["tables"][0]["truncated"], len(jobs["tables"][0]["rows"])), (True, True, 2))
0118
0119 self.assertEqual((tasks["truncated"], tasks["tables"][0]["truncated"]), (True, False))
0120
0121 def test_rejected_query_does_not_stop_the_batch(self) -> None:
0122 data, _, _ = self.run_batch([{"tables": ["users"]}, Q_JOBS, Q_TASKS])
0123 rejected, jobs, tasks = data["answers"]
0124 self.assertEqual((rejected["status"], rejected["rule"]), ("rejected", "unknown_table"))
0125 self.assertEqual(jobs["status"], "done")
0126
0127 self.assertEqual((tasks["status"], tasks["rule"]), ("rejected", "from_query"))
0128 self.assertEqual(tasks["message"], "query 0 has no rows to take values from, being rejected")
0129 self.assertEqual(tasks["detail"], {"query": 0, "status": "rejected"})
0130
0131 def test_batch_budget(self) -> None:
0132
0133 self.assertEqual(self.run_batch([])[2].probes_left, 3000)
0134
0135 q_one = dict(Q_JOBS, where=[["pandaid", "=", 1]])
0136 data, _, batch = self.run_batch([Q_JOBS, Q_JOBS, q_one], probes_left=4)
0137 first, second, third = data["answers"]
0138 self.assertEqual((first["status"], second["status"], third["status"]), ("done", "rejected", "done"))
0139 self.assertEqual(second["rule"], "batch_budget")
0140 self.assertEqual(second["detail"], {"probes": 3, "left": 1, "budget": 3000})
0141 self.assertEqual(batch.probes_left, 0)
0142
0143 def test_partitions_charged_up_to_as_of(self) -> None:
0144
0145 with mock.patch.object(db_read_handlers, "check_query", wraps=db_read_handlers.check_query) as check:
0146 self.run_batch([Q_JOBS])
0147 self.assertEqual(check.call_args.kwargs["now"], AS_OF)
0148
0149 def test_from_query_column_must_be_returned(self) -> None:
0150 q_tasks = dict(Q_TASKS, where=[["jeditaskid", "in", {"from_query": 0, "column": "jobstatus"}]])
0151 data, _, _ = self.run_batch([Q_JOBS, q_tasks])
0152 self.assertEqual(data["answers"][1]["rule"], "from_query")
0153
0154 def test_database_error_stops_the_batch(self) -> None:
0155 def answer(sql: str, binds: dict[str, Any]) -> list[tuple[Any, ...]]:
0156 if ".jobsActive4 " in sql:
0157 raise RuntimeError("ORA-00942: table or view does not exist")
0158 return table_answer(sql, binds)
0159
0160 data, proxy, _ = self.run_batch([Q_JOBS, Q_TASKS], answer=answer)
0161 failed, after = data["answers"]
0162 self.assertEqual((failed["status"], failed["code"]), ("error", "ORA-00942"))
0163 self.assertEqual((after["status"], after["code"]), ("error", "not_run"))
0164
0165 def test_deadline(self) -> None:
0166 batch_box: list[_Batch] = []
0167
0168 def answer(sql: str, binds: dict[str, Any]) -> list[tuple[Any, ...]]:
0169
0170 if ".jobsActive4 " in sql:
0171 batch_box[0].deadline = time.monotonic() - 1
0172 return table_answer(sql, binds)
0173
0174 proxy = FakeProxy(answer)
0175 batch = _Batch(proxy, time.monotonic() + 300, 1000, mock.Mock())
0176 batch_box.append(batch)
0177 data = batch.run([Q_JOBS, Q_TASKS, Q_JOBS])
0178 self.assertEqual([answer["status"] for answer in data["answers"]], ["done", "deadline", "deadline"])
0179
0180 def test_call_timeout(self) -> None:
0181 def answer(sql: str, binds: dict[str, Any]) -> list[tuple[Any, ...]]:
0182 if ".jobsActive4 " in sql:
0183 raise RuntimeError("DPI-1067: call timeout of 30000 ms exceeded with ORA-3156")
0184 return table_answer(sql, binds)
0185
0186
0187 data, proxy, _ = self.run_batch([Q_JOBS, Q_TASKS], answer=answer)
0188 self.assertEqual([(a["status"], a.get("code")) for a in data["answers"]], [("error", "statement_timeout"), ("error", "not_run")])
0189 self.assertTrue(data["answers"][0]["message"].startswith("a round trip ran over the limit of 30 s (DPI-1067): narrow the query"))
0190
0191 data, proxy, _ = self.run_batch([Q_JOBS, Q_TASKS], answer=answer, seconds=5)
0192 self.assertEqual([a["status"] for a in data["answers"]], ["deadline", "deadline"])
0193
0194 def test_result_size(self) -> None:
0195
0196 sql_bytes = len(self.run_batch([Q_JOBS])[0]["answers"][0]["tables"][0]["statement"])
0197 data, _, _ = self.run_batch([Q_JOBS, Q_JOBS], bytes_left=sql_bytes + 100)
0198 jobs, after = data["answers"]
0199 self.assertEqual((jobs["status"], jobs["truncated"], len(jobs["tables"][0]["rows"])), ("done", True, 1))
0200 self.assertEqual((after["status"], after["rule"]), ("rejected", "result_size"))
0201
0202 def test_encode_value(self) -> None:
0203 self.assertEqual(encode_value(D0), {"__datetime__": "2026-10-01T00:00:00"})
0204 self.assertEqual(encode_value(datetime.date(2026, 10, 1)), {"__datetime__": "2026-10-01"})
0205 self.assertEqual((encode_value(decimal.Decimal("12")), encode_value(decimal.Decimal("1.5"))), (12, 1.5))
0206 self.assertEqual(encode_value(b"\x01\xff"), "01ff")
0207 self.assertEqual(encode_value(SimpleNamespace(read=lambda: "large text")), "large text")
0208 self.assertEqual(json.dumps(encode_value(None)), "null")
0209
0210
0211 class TestDeadline(unittest.TestCase):
0212 def test_batch_deadline(self) -> None:
0213
0214 self.assertEqual(batch_deadline(1000.0, 900.0, 300, 480), 900.0 + 300 - 30)
0215 self.assertEqual(batch_deadline(1000.0, None, 300, 480), 1000.0 + 300 - 30)
0216
0217 self.assertEqual(batch_deadline(1000.0, 900.0, 3600, 480), 1000.0 + 480 - 30)
0218
0219 self.assertEqual(batch_deadline(1000.0, 900.0, None, 480), 1000.0 + 480 - 30)
0220
0221 def test_daemon_timeout(self) -> None:
0222 self.assertEqual(daemon_config.get_timeout({"period": 10}), 30)
0223 self.assertEqual(daemon_config.get_timeout({"period": 2400}), 6000)
0224 self.assertEqual(daemon_config.get_timeout({"period": 10, "timeout": 300}), 300)
0225
0226 def test_find_daemon(self) -> None:
0227 config = {
0228 "old": {"module": "async_request_daemon", "enable": False, "period": 5},
0229 "async_request_daemon": {"enable": True, "period": 10, "timeout": 300},
0230 }
0231 for value in (config, json.dumps(config)):
0232 with mock.patch.object(daemon_config, "config", value, create=True):
0233 self.assertEqual(daemon_config.find_daemon("async_request_daemon"), config["async_request_daemon"])
0234 self.assertIsNone(daemon_config.find_daemon("nosuch"))
0235
0236
0237 class TestRun(BatchTestBase):
0238 def setUp(self) -> None:
0239 super().setUp()
0240 self.proxy = FakeProxy(table_answer)
0241 patches: list[Any] = [
0242 mock.patch.object(db_read_handlers, "_connect_reader", side_effect=lambda: self.proxy),
0243 mock.patch.object(daemon_config, "find_daemon", return_value={"period": 10, "timeout": 300}),
0244 mock.patch.object(panda_config, "backend", "oracle"),
0245 mock.patch.object(panda_config, "db_read_probe_budget", 1000, create=True),
0246 ]
0247 for patch in patches:
0248 patch.start()
0249 self.addCleanup(patch.stop)
0250
0251 def test_answers(self) -> None:
0252 logger = mock.Mock()
0253 queries = [Q_JOBS, {"tables": ["users"]}]
0254 success, message, data = db_read_handlers._run({"queries": queries, "requester": "alice"}, time.monotonic(), logger)
0255 self.assertTrue(success, message)
0256 assert data is not None
0257 self.assertEqual([answer["status"] for answer in data["answers"]], ["done", "rejected"])
0258
0259 line = logger.info.call_args[0][0]
0260 prefix, summary = line.split(": ", 1)
0261 self.assertTrue(prefix.startswith("db_read by alice in "), prefix)
0262 for step in ("connect", "start", "dictionary", "close"):
0263 self.assertIn(f"{step} ", prefix)
0264 logged = json.loads(summary)
0265 table = logged[0]["tables"][0]
0266 self.assertEqual((table["table"], table["probes"], table["rows"]), ("jobsActive4", 3, 3))
0267 self.assertEqual(table["statement"], data["answers"][0]["tables"][0]["statement"])
0268 self.assertEqual(sorted(table["binds"].values()), [1, 2, 3])
0269
0270 self.assertEqual(logged[1]["rule"], "unknown_table")
0271 self.assertIn("users", logged[1]["message"])
0272
0273 self.assertTrue(self.proxy.closed)
0274
0275 def test_closed_when_the_transaction_does_not_start(self) -> None:
0276 def answer(sql: str, binds: dict[str, Any]) -> list[tuple[Any, ...]]:
0277 raise RuntimeError("ORA-01031: insufficient privileges")
0278
0279 self.proxy = FakeProxy(answer)
0280 success, message, _ = db_read_handlers._run({"queries": [Q_JOBS]}, time.monotonic(), mock.Mock())
0281 self.assertEqual((success, message), (False, "the read-only transaction could not start: ORA-01031"))
0282 self.assertTrue(self.proxy.closed)
0283
0284 def test_refusals(self) -> None:
0285 logger = mock.Mock()
0286 self.assertFalse(db_read_handlers._run({"queries": []}, time.monotonic(), logger)[0])
0287 self.assertFalse(db_read_handlers._run({"queries": [Q_JOBS] * 11}, time.monotonic(), logger)[0])
0288
0289 with mock.patch.object(daemon_config, "find_daemon", return_value={"period": 10}):
0290 success, message, _ = db_read_handlers._run({"queries": [Q_JOBS]}, time.monotonic(), logger)
0291 self.assertFalse(success)
0292 self.assertIn("no time left", message)
0293
0294 def test_run_by_hand(self) -> None:
0295
0296 started = time.monotonic()
0297 with (
0298 mock.patch.object(daemon_config, "find_daemon", return_value=None),
0299 mock.patch.object(db_read_handlers, "_Batch", wraps=_Batch) as batch_class,
0300 ):
0301 success, message, _ = db_read_handlers._run({"queries": [Q_JOBS]}, started, mock.Mock())
0302 self.assertTrue(success, message)
0303 self.assertEqual(batch_class.call_args[0][1], started + 480 - 30)
0304
0305
0306 class TestReader(unittest.TestCase):
0307 def test_not_configured(self) -> None:
0308 with mock.patch.object(panda_config, "dbuser_reader", None, create=True):
0309 with self.assertRaises(DBReaderUnavailableError):
0310 db_read_handlers._connect_reader()
0311
0312 def test_connected_for_each_request(self) -> None:
0313 proxy_class = mock.Mock()
0314 proxy_class.return_value.connect.return_value = True
0315 with (
0316 mock.patch.object(db_read_handlers, "DBProxy", proxy_class),
0317 mock.patch.object(panda_config, "dbuser_reader", "ATLAS_PANDA_READER", create=True),
0318 mock.patch.object(panda_config, "dbpasswd_reader", "secret", create=True),
0319 mock.patch.object(panda_config, "dbhost_reader", None, create=True),
0320 mock.patch.object(panda_config, "dbhost", "ADCR", create=True),
0321 mock.patch.object(panda_config, "db_read_call_timeout", 30, create=True),
0322 ):
0323 db_read_handlers._connect_reader()
0324 db_read_handlers._connect_reader()
0325 self.assertEqual(proxy_class.call_count, 2)
0326 proxy_class.return_value.connect.assert_called_with(dbhost="ADCR", dbpasswd="secret", dbuser="ATLAS_PANDA_READER", call_timeout_ms=30000)
0327
0328 proxy_class.return_value.connect.return_value = False
0329 with self.assertRaises(DBReaderUnavailableError):
0330 db_read_handlers._connect_reader()
0331 self.assertEqual(proxy_class.return_value.connect.call_count, 3)
0332
0333
0334 class TestLogLine(unittest.TestCase):
0335 def test_binds_cut_short(self) -> None:
0336 binds = {f":r{i}": i for i in range(25)} | {":t": "x" * 150, ":d": D0}
0337 shown = db_read_handlers._binds_for_log(binds)
0338 assert shown is not None
0339 self.assertEqual(len(shown), db_read_handlers.LOG_BINDS + 1)
0340 self.assertEqual(shown["..."], "7 more")
0341 self.assertEqual(db_read_handlers._binds_for_log({":t": "x" * 150, ":d": D0}), {":t": "x" * 100 + "...", ":d": {"__datetime__": "2026-10-01T00:00:00"}})
0342 self.assertIsNone(db_read_handlers._binds_for_log(None))
0343
0344
0345 class TestHandler(unittest.TestCase):
0346 ROW = {"request_id": "some-uuid", "parameters": json.dumps({"queries": [Q_JOBS], "requester": "alice"})}
0347
0348 def test_result_is_stored(self) -> None:
0349 task_buffer = mock.Mock()
0350 with mock.patch.object(db_read_handlers, "_run", return_value=(False, "no time left", None)):
0351 db_read_handlers._handle_db_read(self.ROW, task_buffer, mock.Mock(), "any")
0352 task_buffer.finish_async_result.assert_called_once_with(
0353 "some-uuid", "any", "done", result=json.dumps({"success": False, "message": "no time left", "data": None})
0354 )
0355
0356 def test_reader_not_configured(self) -> None:
0357
0358 task_buffer = mock.Mock()
0359 logger = mock.Mock()
0360 with (
0361 mock.patch.object(daemon_config, "find_daemon", return_value={"period": 10, "timeout": 300}),
0362 mock.patch.object(panda_config, "backend", "oracle"),
0363 mock.patch.object(panda_config, "dbuser_reader", None, create=True),
0364 mock.patch.object(db_read_handlers, "DBProxy") as proxy_class,
0365 ):
0366 db_read_handlers._handle_db_read(self.ROW, task_buffer, logger, "any")
0367 proxy_class.assert_not_called()
0368 message = "the read-only account is not available: dbuser_reader is not set in panda_server.cfg"
0369 task_buffer.finish_async_result.assert_called_once_with(
0370 "some-uuid", "any", "done", result=json.dumps({"success": False, "message": message, "data": None})
0371 )
0372
0373 logger.error.assert_called_once_with(f"db_read by alice not run: {message}")
0374
0375 def test_crash_is_stored_as_failed(self) -> None:
0376 task_buffer = mock.Mock()
0377 with mock.patch.object(db_read_handlers, "_run", side_effect=KeyError("boom")):
0378 db_read_handlers._handle_db_read(self.ROW, task_buffer, mock.Mock(), "any")
0379 args, kwargs = task_buffer.finish_async_result.call_args
0380 self.assertEqual(args, ("some-uuid", "any", "failed"))
0381 self.assertFalse(kwargs["retriable"])
0382 self.assertIn("KeyError", kwargs["error_msg"])
0383
0384
0385 if __name__ == "__main__":
0386 unittest.main()