Warning, file /panda-server/pandaserver/asyncprocess/db_read_handlers.py was not indexed
or was modified since last indexation (in which case cross-reference links may be missing, inaccurate or erroneous).
0001 """
0002 Async request handler for the reads of the database, registered into processor.HANDLERS.
0003
0004 A db_read request holds a batch of queries, whose form is in
0005 pandaserver.asyncprocess.db_read.request_shape. The queries run on the read-only account
0006 of panda_config.dbuser_reader and never on the account of the task buffer the handler is
0007 given, which only stores the result. Each request connects to the account and closes the
0008 connection when it is done, so the account has a session only while a request runs. The
0009 queries run in one read-only transaction, so every statement sees the database at the same
0010 moment, and the answer gives that moment as as_of, on the clock of the session, which is UTC.
0011
0012 The stored result is {"success", "message", "data"}, with data {"as_of", "answers"} and one
0013 answer per query, in order:
0014
0015 {"status": "done", "truncated": bool, "tables": [{"table", "rows", "truncated", "probes",
0016 "seconds", "statement"}, ...]}
0017 Rows are objects keyed by the lower-case names the query returns. An answer is
0018 truncated when a table was cut at the limit or at the size of the result, or when an
0019 "in" took its values from a truncated answer: a cut set of keys must not look whole.
0020 {"status": "rejected", "rule": str, "message": str, "detail": {...}}
0021 The query broke a rule of request_shape, or result_size when the result had no room
0022 left for it, or batch_budget when the queries before it left too few of the index
0023 lookups the batch may make, BATCH_BUDGET_QUERIES times db_read_probe_budget.
0024 {"status": "deadline"}
0025 Not run, the batch having run out of time.
0026 {"status": "error", "code": str, "message": str}
0027 The database failed, with its ORA-, DPI-, DPY- or SQLSTATE code, or table_not_visible
0028 when the dictionary shows no columns of a table, which is what a missing grant looks
0029 like, or statement_timeout when a round trip ran over the limit of the connection,
0030 db_read_call_timeout, rather than over the time left: the query is too heavy and has to
0031 be narrowed. The batch stops at a database error, since a Postgres transaction takes no
0032 statement after one; the queries after it are errors with code not_run.
0033
0034 The batch has a deadline, before each round trip to the database the limit of that round
0035 trip is lowered to the time left, and nothing runs past it. The deadline keeps MARGIN_SECONDS
0036 back for storing the result, both from the daemon's timeout, which counts from the start of
0037 the processing cycle, and from the age at which processor.run hands a running request out
0038 again, which counts from the start of the request. When the daemon config has no enabled
0039 entry running async_request_daemon, the daemon master does not run it, nor kill it: it was
0040 started by hand, and only the age counts.
0041 """
0042
0043 import datetime
0044 import decimal
0045 import json
0046 import time
0047 import traceback
0048 from typing import Any
0049
0050 from pandacommon.pandalogger.LogWrapper import LogWrapper
0051
0052 from pandaserver.asyncprocess import TaskBufferLike
0053 from pandaserver.asyncprocess.db_read.compose import compose_statement
0054 from pandaserver.asyncprocess.db_read.request_shape import (
0055 MAX_QUERIES,
0056 FromQuery,
0057 QueryRejected,
0058 check_query,
0059 parse_query,
0060 )
0061 from pandaserver.asyncprocess.db_read.schema_info import (
0062 STATEMENT_COMMENT,
0063 TableNotVisible,
0064 get_table_info,
0065 )
0066 from pandaserver.config import daemon_config, panda_config
0067 from pandaserver.srvcore.exceptions import DBReadDeadlineError, DBReaderUnavailableError
0068 from pandaserver.taskbuffer.db_proxy_mods.base_module import get_db_error_code
0069 from pandaserver.taskbuffer.OraDBProxy import DBProxy
0070
0071 REQUEST_TYPE = "db_read"
0072
0073
0074 DAEMON_MODULE = "async_request_daemon"
0075
0076
0077 MARGIN_SECONDS = 30
0078
0079
0080
0081 BATCH_BUDGET_QUERIES = 3
0082
0083
0084 RESULT_BYTES = 1_000_000
0085 ENVELOPE_BYTES = 20_000
0086
0087
0088 _TIMEOUT_CODES = ("DPI-1067", "DPY-4024", "57014")
0089
0090
0091 LOG_BINDS = 20
0092 LOG_TEXT_CHARS = 100
0093
0094
0095 def _connect_reader() -> DBProxy:
0096 """
0097 Connect to the read-only account for one request.
0098
0099 The daemon master does not start async_request_daemon while a run of it goes on, so a machine
0100 has at most one such connection, and only while a request runs. There is one attempt: the
0101 handler runs under a deadline, and the caller can submit again.
0102
0103 Returns:
0104 DBProxy: the connected proxy, to be closed with cleanup()
0105
0106 Raises:
0107 DBReaderUnavailableError: when panda_config has no dbuser_reader, or the connection fails
0108 """
0109 if not panda_config.dbuser_reader:
0110 raise DBReaderUnavailableError("dbuser_reader is not set in panda_server.cfg")
0111 proxy = DBProxy()
0112 if not proxy.connect(
0113 dbhost=panda_config.dbhost_reader or panda_config.dbhost,
0114 dbpasswd=panda_config.dbpasswd_reader or "",
0115 dbuser=panda_config.dbuser_reader,
0116 call_timeout_ms=panda_config.db_read_call_timeout * 1000,
0117 ):
0118 raise DBReaderUnavailableError(f"{panda_config.dbuser_reader} failed to connect")
0119 return proxy
0120
0121
0122 def batch_deadline(request_started: float, cycle_started: float | None, daemon_timeout: int | None, stale_seconds: int) -> float:
0123 """
0124 Get the time by which a batch must be done.
0125
0126 Args:
0127 request_started(float): time.monotonic() when the handler started
0128 cycle_started(float): time.monotonic() when the processing cycle started, or None when unknown
0129 daemon_timeout(int): seconds a cycle may run before the daemon master kills the worker, or None
0130 when no daemon master runs the cycle
0131 stale_seconds(int): seconds a request may run before processor.run hands it out again
0132
0133 Returns:
0134 float: the deadline, on the clock of time.monotonic()
0135 """
0136 deadline = request_started + stale_seconds
0137 if daemon_timeout is not None:
0138 cycle = request_started if cycle_started is None else cycle_started
0139 deadline = min(cycle + daemon_timeout, deadline)
0140 return deadline - MARGIN_SECONDS
0141
0142
0143 def encode_value(value: Any) -> Any:
0144 """
0145 Get a value of a row as JSON can hold it, times the way the API writes them.
0146
0147 Args:
0148 value(Any): the value the driver gives
0149
0150 Returns:
0151 Any: the value for json.dumps
0152 """
0153 if isinstance(value, (datetime.datetime, datetime.date)):
0154 return {"__datetime__": value.isoformat()}
0155 if isinstance(value, decimal.Decimal):
0156 return int(value) if value == value.to_integral_value() else float(value)
0157 if isinstance(value, (bytes, bytearray, memoryview)):
0158 return bytes(value).hex()
0159 if hasattr(value, "read"):
0160
0161 return encode_value(value.read())
0162 return value
0163
0164
0165 def _rejected(rejection: QueryRejected) -> dict[str, Any]:
0166 return {"status": "rejected", "rule": rejection.rule, "message": rejection.message, "detail": rejection.detail}
0167
0168
0169 class _Batch:
0170 """The queries of one request, on one connection of the read-only account."""
0171
0172 def __init__(self, proxy: DBProxy, deadline: float, budget: int, tmp_logger: LogWrapper) -> None:
0173 self.proxy = proxy
0174 self.backend = proxy.backend
0175 self.deadline = deadline
0176 self.budget = budget
0177 self.probes_left = BATCH_BUDGET_QUERIES * budget
0178 self.tmp_logger = tmp_logger
0179 self.bytes_left = RESULT_BYTES - ENVELOPE_BYTES
0180
0181 self.limited_by_deadline = False
0182
0183 self.answers: list[dict[str, Any]] = []
0184 self.rows: list[list[tuple[tuple[str, ...], list[tuple[Any, ...]]]]] = []
0185
0186 self.stopped: dict[str, Any] | None = None
0187
0188 self.as_of: datetime.datetime | None = None
0189
0190
0191 self.binds: list[list[dict[str, Any] | None]] = []
0192 self.start_seconds = 0.0
0193 self.dictionary_seconds = 0.0
0194
0195 def limit_round_trip(self) -> None:
0196 left_ms = int((self.deadline - time.monotonic()) * 1000)
0197 if left_ms < 1:
0198 raise DBReadDeadlineError()
0199 cap_ms = self.proxy.call_timeout_ms
0200 self.limited_by_deadline = cap_ms is None or left_ms < cap_ms
0201 self.proxy.set_call_timeout(left_ms)
0202
0203 def round_trip(self, sql: str, binds: dict[str, Any]) -> list[tuple[Any, ...]]:
0204 self.limit_round_trip()
0205 self.proxy.cur.execute(sql, binds)
0206 rows: list[tuple[Any, ...]] = self.proxy.cur.fetchall()
0207 return rows
0208
0209 def start(self) -> datetime.datetime:
0210
0211 self.proxy.conn.rollback()
0212 if self.backend == "postgres":
0213 sql = "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"
0214 else:
0215 sql = "SET TRANSACTION READ ONLY"
0216
0217
0218 self.proxy.cur.execute(sql + STATEMENT_COMMENT, {})
0219 self.limit_round_trip()
0220 return self.proxy.get_db_utc_now()
0221
0222 def rollback(self) -> None:
0223
0224 try:
0225 self.proxy.conn.rollback()
0226 except Exception:
0227 self.tmp_logger.warning(f"failed to roll back: {traceback.format_exc()}")
0228
0229 def values_of(self, reference: FromQuery) -> list[Any]:
0230
0231 values: dict[Any, None] = {}
0232 for names, rows in self.rows[reference.query]:
0233 if reference.column in names:
0234 index = names.index(reference.column)
0235 values.update((row[index], None) for row in rows if row[index] is not None)
0236 return list(values)
0237
0238 def answer(self, position: int, raw: Any) -> dict[str, Any]:
0239 try:
0240 query = parse_query(raw, position)
0241 except QueryRejected as rejection:
0242 return _rejected(rejection)
0243
0244 from_values: dict[FromQuery, list[Any]] = {}
0245 from_truncated = False
0246 for reference in query.from_queries():
0247 source = self.answers[reference.query]
0248 if source["status"] != "done":
0249
0250 message = f"query {reference.query} has no rows to take values from, being {source['status']}"
0251 return _rejected(QueryRejected("from_query", message, query=reference.query, status=source["status"]))
0252 if not any(reference.column in names for names, _ in self.rows[reference.query]):
0253 return _rejected(QueryRejected("from_query", f"query {reference.query} does not return {reference.column}", query=reference.query))
0254 from_values[reference] = self.values_of(reference)
0255 from_truncated = from_truncated or source["truncated"]
0256 dictionary_started = time.monotonic()
0257 try:
0258 infos = {table.name: get_table_info(self.round_trip, self.backend, table.owner, table.name) for table in query.tables}
0259 except TableNotVisible as error:
0260 return {"status": "error", "code": "table_not_visible", "message": f"the dictionary shows no columns of {error}"}
0261 finally:
0262 self.dictionary_seconds += time.monotonic() - dictionary_started
0263 try:
0264 plans = check_query(query, infos, {reference: len(values) for reference, values in from_values.items()}, self.budget, now=self.as_of)
0265 except QueryRejected as rejection:
0266 return _rejected(rejection)
0267
0268 cost = sum(plan.probes for plan in plans)
0269 if cost > self.probes_left:
0270 message = f"the query costs {cost} index lookups, over the {self.probes_left} left of the batch's {BATCH_BUDGET_QUERIES * self.budget}"
0271 return _rejected(QueryRejected("batch_budget", message, probes=cost, left=self.probes_left, budget=BATCH_BUDGET_QUERIES * self.budget))
0272 self.probes_left -= cost
0273 tables: list[dict[str, Any]] = []
0274 raw_tables: list[tuple[tuple[str, ...], list[tuple[Any, ...]]]] = []
0275 truncated = from_truncated
0276 for plan in plans:
0277 statement = compose_statement(plan, query, from_values, self.backend)
0278 if statement is None:
0279
0280 tables.append({"table": plan.table.name, "rows": [], "truncated": False, "probes": 0, "seconds": 0.0, "statement": None})
0281 self.binds[position].append(None)
0282 continue
0283 if len(statement.sql) > self.bytes_left:
0284 self.stopped = {"status": "rejected", "rule": "result_size", "message": "the result has no room left", "detail": {}}
0285 return dict(self.stopped)
0286 self.bytes_left -= len(statement.sql)
0287 self.proxy.cur.arraysize = statement.limit + 1
0288 started = time.monotonic()
0289 rows = self.round_trip(statement.sql, statement.binds)
0290 seconds = round(time.monotonic() - started, 3)
0291 cut = len(rows) > statement.limit
0292 encoded: list[dict[str, Any]] = []
0293 for row in rows[: statement.limit]:
0294 item = {name: encode_value(value) for name, value in zip(statement.names, row)}
0295 size = len(json.dumps(item)) + 2
0296 if size > self.bytes_left:
0297 cut = True
0298 self.stopped = {"status": "rejected", "rule": "result_size", "message": "the result has no room left", "detail": {}}
0299 break
0300 self.bytes_left -= size
0301 encoded.append(item)
0302 raw_tables.append((statement.names, rows[: len(encoded)]))
0303 truncated = truncated or cut
0304 tables.append({"table": plan.table.name, "rows": encoded, "truncated": cut, "probes": plan.probes, "seconds": seconds, "statement": statement.sql})
0305 self.binds[position].append(statement.binds)
0306 if self.stopped is not None:
0307
0308 break
0309 self.rows[position] = raw_tables
0310 return {"status": "done", "truncated": truncated, "tables": tables}
0311
0312 def run(self, raw_queries: list[Any]) -> dict[str, Any]:
0313 started = time.monotonic()
0314 as_of = self.as_of = self.start()
0315 self.start_seconds = time.monotonic() - started
0316 for position, raw in enumerate(raw_queries):
0317 self.rows.append([])
0318 self.binds.append([])
0319 if self.stopped is not None:
0320 self.answers.append(dict(self.stopped))
0321 continue
0322 try:
0323 answer = self.answer(position, raw)
0324 except DBReadDeadlineError:
0325 answer = {"status": "deadline"}
0326 self.stopped = answer
0327 except Exception as error:
0328 code = get_db_error_code(error)
0329 if code in _TIMEOUT_CODES and self.limited_by_deadline:
0330 answer = {"status": "deadline"}
0331 self.stopped = answer
0332 elif code in _TIMEOUT_CODES:
0333
0334 limit = (self.proxy.call_timeout_ms or 0) / 1000
0335 self.tmp_logger.warning(f"query {position} ran over the limit of {limit:g} s of a round trip with {code}")
0336 message = f"a round trip ran over the limit of {limit:g} s ({code}): narrow the query with a window on the partition key or fewer keys"
0337 answer = {"status": "error", "code": "statement_timeout", "message": message}
0338 self.stopped = {"status": "error", "code": "not_run", "message": f"not run after the timeout of query {position}"}
0339 else:
0340 self.tmp_logger.error(f"query {position} failed with {code}: {traceback.format_exc()}")
0341 answer = {"status": "error", "code": code, "message": str(error)[:1000]}
0342 self.stopped = {"status": "error", "code": "not_run", "message": f"not run after the error of query {position}"}
0343 self.rollback()
0344 self.answers.append(answer)
0345 self.rollback()
0346 return {"as_of": encode_value(as_of), "answers": self.answers}
0347
0348
0349 def _binds_for_log(binds: dict[str, Any] | None) -> dict[str, Any] | None:
0350
0351 if binds is None:
0352 return None
0353 shown: dict[str, Any] = {}
0354 for name, value in list(binds.items())[:LOG_BINDS]:
0355 value = encode_value(value)
0356 if isinstance(value, str) and len(value) > LOG_TEXT_CHARS:
0357 value = value[:LOG_TEXT_CHARS] + "..."
0358 shown[name] = value
0359 if len(binds) > LOG_BINDS:
0360 shown["..."] = f"{len(binds) - LOG_BINDS} more"
0361 return shown
0362
0363
0364 def _summary(answers: list[dict[str, Any]], binds: list[list[dict[str, Any] | None]]) -> list[dict[str, Any]]:
0365
0366 summary: list[dict[str, Any]] = []
0367 for answer, answer_binds in zip(answers, binds):
0368 item: dict[str, Any] = {key: answer[key] for key in ("status", "rule", "code", "message", "detail") if key in answer}
0369 if "tables" in answer:
0370 item["tables"] = [
0371 {key: table[key] for key in ("table", "probes", "seconds", "truncated")}
0372 | {"rows": len(table["rows"]), "statement": table["statement"], "binds": _binds_for_log(table_binds)}
0373 for table, table_binds in zip(answer["tables"], answer_binds)
0374 ]
0375 summary.append(item)
0376 return summary
0377
0378
0379 def _run(parameters: dict[str, Any], started: float, tmp_logger: LogWrapper) -> tuple[bool, str, dict[str, Any] | None]:
0380 queries = parameters.get("queries")
0381 if not isinstance(queries, list) or not 1 <= len(queries) <= MAX_QUERIES:
0382 return False, f"queries must be a list of 1 to {MAX_QUERIES} queries", None
0383 if panda_config.backend not in ("oracle", "postgres"):
0384 return False, f"db_read is not available with {panda_config.backend}", None
0385
0386 from pandaserver.asyncprocess import processor
0387
0388 daemon = daemon_config.find_daemon(DAEMON_MODULE)
0389 if daemon is None:
0390
0391 daemon_timeout = None
0392 tmp_logger.info(f"no enabled {DAEMON_MODULE} in the daemon config, so run by hand with no timeout but the age of the request")
0393 else:
0394 daemon_timeout = daemon_config.get_timeout(daemon)
0395 deadline = batch_deadline(started, processor.get_cycle_started_at(), daemon_timeout, processor._STALE_THRESHOLD_SECONDS)
0396 if deadline <= time.monotonic():
0397 return (
0398 False,
0399 f"no time left for the queries: {DAEMON_MODULE} has a timeout of {daemon_timeout} s, {MARGIN_SECONDS} s of which are kept "
0400 "for the result, and the cycle may have used the rest on other requests",
0401 None,
0402 )
0403 connect_started = time.monotonic()
0404 try:
0405 proxy = _connect_reader()
0406 except DBReaderUnavailableError as error:
0407 return False, f"the read-only account is not available: {error}", None
0408 connect_seconds = time.monotonic() - connect_started
0409 batch = _Batch(proxy, deadline, panda_config.db_read_probe_budget, tmp_logger)
0410 try:
0411 try:
0412 data = batch.run(queries)
0413 except Exception as error:
0414
0415 code = get_db_error_code(error)
0416 tmp_logger.error(f"failed to start the read-only transaction with {code}: {traceback.format_exc()}")
0417 return False, f"the read-only transaction could not start: {code}", None
0418 finally:
0419 close_started = time.monotonic()
0420 proxy.cleanup()
0421 close_seconds = time.monotonic() - close_started
0422
0423 steps = {"connect": connect_seconds, "start": batch.start_seconds, "dictionary": batch.dictionary_seconds, "close": close_seconds}
0424 seconds = round(time.monotonic() - started, 3)
0425 tmp_logger.info(
0426 f"db_read by {parameters.get('requester')} in {seconds} s ({', '.join(f'{name} {value:.3f} s' for name, value in steps.items())}): "
0427 f"{json.dumps(_summary(data['answers'], batch.binds), default=str)}"
0428 )
0429 return True, "", data
0430
0431
0432 def _handle_db_read(row: dict[str, Any], tb: TaskBufferLike, tmp_logger: LogWrapper, result_machine: str) -> None:
0433 """
0434 Run the queries of a db_read request and store the answers as its result.
0435
0436 Args:
0437 row(dict): the async_requests row to process
0438 tb(TaskBuffer): task buffer, used only to store the result
0439 tmp_logger(LogWrapper): logger of the processing cycle
0440 result_machine(str): machine_name the result row is keyed by
0441 """
0442 request_id = row["request_id"]
0443 started = time.monotonic()
0444 try:
0445 parameters = json.loads(row["parameters"] or "{}")
0446 success, message, data = _run(parameters, started, tmp_logger)
0447 result = json.dumps({"success": success, "message": message, "data": data})
0448 except Exception:
0449
0450
0451
0452 err_msg = traceback.format_exc()
0453 tmp_logger.error(f"db_read failed with {err_msg}")
0454 tb.finish_async_result(request_id, result_machine, "failed", error_msg=err_msg, retriable=False)
0455 return
0456 if not success:
0457
0458
0459 tmp_logger.error(f"db_read by {parameters.get('requester')} not run: {message}")
0460 tb.finish_async_result(request_id, result_machine, "done", result=result)
0461
0462
0463 HANDLERS = {REQUEST_TYPE: _handle_db_read}