Back to home page

EIC code displayed by LXR

 
 

    


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 # Description: Unit tests for the handler of the async db_read requests: the batch, its deadline
0002 # and the result it stores. The read-only connection is a fake, and no database is used
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     # what _Batch uses of a DBProxy of the read-only account
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     # the rows of the table a statement reads, whatever its conditions
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())  # type: ignore[arg-type]
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         # the read-only transaction comes first, and the values of the in are taken each once
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         # each round trip after the first statement, as_of and the two queries, is limited to the time
0108         # left, which DBProxy.set_call_timeout cuts down to the connection's own limit
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         # before the read-only transaction, and at its end
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         # its own table was not cut, but its keys were
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         # query 0 has no rows to take values from, which the answer says is because it was rejected
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         # three budgets of one query for the batch
0133         self.assertEqual(self.run_batch([])[2].probes_left, 3000)
0134         # each Q_JOBS costs 3, so the second is over what is left, and a cheaper one after it still runs
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         # the moment the transaction sees is the present the partitions made ahead are told by
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             # the time runs out while the first query runs
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())  # type: ignore[arg-type]
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         # the connection's own limit ran out: the query is too heavy, which the answer says
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         # the time left ran out first: the batch had no time for it
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         # room for the statement and one row
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         # the cycle started 100 s before the request, and its timeout comes first
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         # a long timeout leaves the age at which a running request is handed out again
0217         self.assertEqual(batch_deadline(1000.0, 900.0, 3600, 480), 1000.0 + 480 - 30)
0218         # with no daemon master, only the age counts
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         # one line saying who read what, with which statement, at what cost, how fast and where the time went
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         # and why a query was refused
0270         self.assertEqual(logged[1]["rule"], "unknown_table")
0271         self.assertIn("users", logged[1]["message"])
0272         # the connection lasts as long as the request
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         # the default timeout of a daemon running every 10 s leaves nothing after the margin
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         # with no enabled entry, no daemon master runs the cycle nor kills it, and only the age of the request counts
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             # one attempt
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         # the request is done, saying why its queries did not run, and nothing connects
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         # and the server's log says it too
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()