Back to home page

EIC code displayed by LXR

 
 

    


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 # the daemon module that runs the requests submitted for the server
0074 DAEMON_MODULE = "async_request_daemon"
0075 
0076 # seconds kept back from the deadline for encoding and storing the result
0077 MARGIN_SECONDS = 30
0078 
0079 # the index lookups a whole batch may make, in budgets of one query: one heavy query leaves room
0080 # for the others, and ten do not make ten times the work of one
0081 BATCH_BUDGET_QUERIES = 3
0082 
0083 # most bytes of the stored result, and the part of them kept for what is not rows or statements
0084 RESULT_BYTES = 1_000_000
0085 ENVELOPE_BYTES = 20_000
0086 
0087 # codes of a round trip that ran out of time
0088 _TIMEOUT_CODES = ("DPI-1067", "DPY-4024", "57014")
0089 
0090 # most binds of a statement the log line shows, and most characters of a text bind it shows
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         # a LOB the output type handler leaves alone, such as a BLOB
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         # whether the limit of the last round trip was the time left rather than the connection's own
0181         self.limited_by_deadline = False
0182         # the answers so far, and for each the names and the rows, as the driver gave them, of each table
0183         self.answers: list[dict[str, Any]] = []
0184         self.rows: list[list[tuple[tuple[str, ...], list[tuple[Any, ...]]]]] = []
0185         # the answer the queries after a stop get, once something has stopped the batch
0186         self.stopped: dict[str, Any] | None = None
0187         # the moment the transaction sees, before which the partitions a query is charged for start
0188         self.as_of: datetime.datetime | None = None
0189         # for the log line: the binds of each table of each answer, None for a table not read, and the
0190         # seconds the start of the transaction and the reads of the dictionary took
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         # end whatever the connection had open, so that the read-only transaction is the next one
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         # the first statement of the transaction, run under the limit of the connection: on
0217         # Postgres the SET LOCAL a lower limit takes is a statement of its own
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         # the connection is closed after the request, so a rollback is all an error or the end needs
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         # the values of a column over every table of an answer, each once, nulls left out
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         # the values of each "in" from an earlier query
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                 # say why, since a query rejected or failed is not one that found nothing
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         # charged before it runs, a query that fails having cost the database all the same
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                 # an "in" from a query with no values, which no row satisfies
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                 # the tables after this one are not read, which the truncated answer says
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                     # the connection's own limit, so the query and not the batch is to blame
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     # the first binds of a statement, a long text cut short, since an "in" may have a thousand
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     # what the log line keeps of each answer: its status and why, and the statement, binds, cost and time of each table
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     # imported here since processor imports this module to register the handler
0386     from pandaserver.asyncprocess import processor
0387 
0388     daemon = daemon_config.find_daemon(DAEMON_MODULE)
0389     if daemon is None:
0390         # the daemon master runs only the enabled entries, reading them as find_daemon does
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             # only the start of the transaction gets here, every query catching its own errors
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     # where the time went besides the statements, whose times are in the summary
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         # processor.run does not catch what a handler raises, so this is where a crash has to end:
0450         # left to it, the request would stay running until handed out again. Running it again
0451         # would only crash the same way
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         # what keeps a whole request from running is the server's cfg, its daemon config or its database,
0458         # since submission has checked the batch, so the operators get it here as well as the caller
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}