Coverage for python/lsst/dax/apdb/sql/apdbSqlReplica.py: 93%
136 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-07 01:39 -0800
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-07 01:39 -0800
1# This file is part of dax_apdb.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
22"""Module defining Apdb class and related methods."""
24from __future__ import annotations
26__all__ = ["ApdbSqlReplica"]
28import logging
29from collections.abc import Collection, Iterable, Mapping, Sequence
30from typing import TYPE_CHECKING, cast
32import astropy.time
33import felis.datamodel
34import numpy
35import pandas
36import sqlalchemy
37from sqlalchemy import sql
39from ..apdbReplica import ApdbReplica, ApdbTableData, ReplicaChunk
40from ..apdbSchema import ApdbTables
41from ..apdbUpdateRecord import ApdbUpdateRecord
42from ..monitor import MonAgent
43from ..schema_model import Column, ExtraDataTypes
44from ..timer import Timer
45from ..versionTuple import VersionTuple
46from .apdbSqlSchema import ExtraTables
48if TYPE_CHECKING:
49 from .apdbSqlSchema import ApdbSqlSchema
52_LOG = logging.getLogger(__name__)
54_MON = MonAgent(__name__)
56VERSION = VersionTuple(1, 0, 0)
57"""Version for the code controlling replication tables. This needs to be
58updated following compatibility rules when schema produced by this code
59changes.
60"""
63class ApdbSqlTableData(ApdbTableData):
64 """Implementation of ApdbTableData that wraps sqlalchemy Result.
66 Parameters
67 ----------
68 result : `sqlalchemy.engine.Result`
69 Result returned from query.
70 column_defs : `list` [`..schema_model.Column`]
71 Column definitions, must include all columns appearing in the
72 ``result``.
73 """
75 def __init__(self, result: sqlalchemy.engine.Result, column_defs: list[Column]):
76 column_map = {column_def.name: column_def for column_def in column_defs}
77 self._column_defs = tuple(column_map[column_name] for column_name in result.keys())
78 column_types = []
79 for column_def in self._column_defs:
80 if isinstance(column_def.datatype, ExtraDataTypes): 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true
81 raise TypeError("Unsupported column type {column_def.datatype} for column {column_def.name}")
82 column_types.append((column_def.name, column_def.datatype))
83 self._column_types = tuple(column_types)
84 self._rows: list[tuple] = cast(list[tuple], list(result.fetchall()))
86 def column_names(self) -> Sequence[str]:
87 return tuple(column_def.name for column_def in self._column_defs)
89 def column_defs(self) -> Sequence[tuple[str, felis.datamodel.DataType]]:
90 return self._column_types
92 def rows(self) -> Collection[tuple]:
93 return self._rows
95 def to_pandas(self) -> pandas.DataFrame:
96 """Convert data to pandas DataFrame.
98 Returns
99 -------
100 dataframe : `pandas.DataFrame`
101 Resulting DataFrame.
102 """
103 if not self._rows:
104 # There could be columns that are not in the configured schema, use
105 # object column type for them.
106 column_data = {}
107 for column_def in self._column_defs:
108 column_data[column_def.name] = pandas.Series(dtype=column_def.pandas_type)
109 return pandas.DataFrame(column_data)
111 # To avoid nested loops convert everything to ndarray.
112 array = numpy.array(self._rows, dtype=object)
113 array = array.T
114 column_data = {}
115 for i, column_def in enumerate(self._column_defs):
116 column_data[column_def.name] = pandas.Series(array[i], dtype=column_def.pandas_type)
117 return pandas.DataFrame(column_data)
120class ApdbSqlReplica(ApdbReplica):
121 """Implementation of `ApdbReplica` for SQL backend.
123 Parameters
124 ----------
125 schema : `ApdbSqlSchema`
126 Instance of `ApdbSqlSchema` class for APDB database.
127 engine : `sqlalchemy.engine.Engine`
128 Engine for database access.
129 db_schema_version : `VersionTuple`
130 Version of the database schema.
131 timer : `bool`, optional
132 If `True` then log timing information.
133 """
135 def __init__(
136 self,
137 schema: ApdbSqlSchema,
138 engine: sqlalchemy.engine.Engine,
139 db_schema_version: VersionTuple,
140 timer: bool = False,
141 ):
142 self._schema = schema
143 self._engine = engine
144 self._db_schema_version = db_schema_version
146 self._timer_args: list[MonAgent | logging.Logger] = [_MON]
147 if timer: 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true
148 self._timer_args.append(_LOG)
150 def close(self) -> None:
151 # docstring is inherited from a base class
152 self._engine.dispose()
154 def _timer(self, name: str, *, tags: Mapping[str, str | int] | None = None) -> Timer:
155 """Create `Timer` instance given its name."""
156 return Timer(name, *self._timer_args, tags=tags)
158 def schemaVersion(self) -> VersionTuple:
159 # Docstring inherited from base class.
160 return self._db_schema_version
162 @classmethod
163 def apdbReplicaImplementationVersion(cls) -> VersionTuple:
164 # Docstring inherited from base class.
165 return VERSION
167 def getReplicaChunks(self) -> list[ReplicaChunk] | None:
168 # docstring is inherited from a base class
169 if not self._schema.replication_enabled:
170 return None
172 table = self._schema.get_table(ExtraTables.ApdbReplicaChunks)
173 assert table is not None, "replication_enabled=True means it must be defined"
174 query = sql.select(
175 table.columns["apdb_replica_chunk"], table.columns["last_update_time"], table.columns["unique_id"]
176 ).order_by(table.columns["last_update_time"])
177 with self._timer("chunks_select_time") as timer:
178 with self._engine.connect() as conn:
179 result = conn.execution_options(stream_results=True, max_row_buffer=10000).execute(query)
180 ids = []
181 for row in result:
182 last_update_time = astropy.time.Time(row[1].timestamp(), format="unix_tai")
183 ids.append(ReplicaChunk(id=row[0], last_update_time=last_update_time, unique_id=row[2]))
184 timer.add_values(row_count=len(ids))
185 return ids
187 def deleteReplicaChunks(self, chunks: Iterable[int]) -> None:
188 # docstring is inherited from a base class
189 if not self._schema.replication_enabled: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true
190 raise ValueError("APDB is not configured for replication")
192 table = self._schema.get_table(ExtraTables.ApdbReplicaChunks)
193 chunk_list = list(chunks)
194 where_clause = table.columns["apdb_replica_chunk"].in_(chunk_list)
195 stmt = table.delete().where(where_clause)
196 with self._timer("chunks_delete_time") as timer:
197 with self._engine.begin() as conn:
198 conn.execute(stmt)
199 timer.add_values(row_count=len(chunk_list))
201 def getTableDataChunks(self, table: ApdbTables, chunks: Iterable[int]) -> ApdbTableData:
202 # docstring is inherited from a base class
203 for chunk_table, table_enum in ExtraTables.replica_chunk_tables().items():
204 if table is table_enum:
205 return self._get_chunks(chunks, table, chunk_table)
206 raise ValueError(f"Table {table} does not support replica chunks.")
208 def _get_chunks(
209 self,
210 chunks: Iterable[int],
211 table_enum: ApdbTables,
212 chunk_table_enum: ExtraTables,
213 ) -> ApdbTableData:
214 """Return catalog of records for given insert identifiers, common
215 implementation for all DIA tables.
216 """
217 if not self._schema.replication_enabled:
218 raise ValueError("APDB is not configured for replication")
220 table = self._schema.get_table(table_enum)
221 chunk_table = self._schema.get_table(chunk_table_enum)
223 join = table.join(chunk_table)
224 chunk_id_column = chunk_table.columns["apdb_replica_chunk"]
225 apdb_columns = self._schema.get_apdb_columns(table_enum)
226 where_clause = chunk_id_column.in_(chunks)
227 query = sql.select(chunk_id_column, *apdb_columns).select_from(join).where(where_clause)
229 table_schema = self._schema.tableSchemas[table_enum]
230 chunk_column_def = Column(
231 name="apdb_replica_chunk", datatype=felis.datamodel.DataType.long, id="", nullable=False
232 )
233 column_defs = table_schema.columns + [chunk_column_def]
235 # execute select
236 with self._timer("table_chunk_select_time", tags={"table": table.name}) as timer:
237 with self._engine.begin() as conn:
238 result = conn.execution_options(stream_results=True, max_row_buffer=10000).execute(query)
239 table_data = ApdbSqlTableData(result, column_defs)
240 timer.add_values(row_count=len(table_data.rows()))
241 return table_data
243 def getUpdateRecordChunks(self, chunks: Iterable[int]) -> Sequence[ApdbUpdateRecord]:
244 # docstring is inherited from a base class
245 if not self._schema.replication_enabled: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 raise ValueError("APDB is not configured for replication")
248 try:
249 table = self._schema.get_table(ExtraTables.ApdbUpdateRecordChunks)
250 except ValueError:
251 # Table does not exist yet.
252 return []
253 query = table.select().where(table.columns["apdb_replica_chunk"].in_(chunks))
255 records = []
256 with self._timer("select_update_record_time", tags={"table": table.name}) as timer:
257 with self._engine.begin() as conn:
258 result = conn.execution_options(stream_results=True, max_row_buffer=10000).execute(query)
259 for row in result:
260 records.append(
261 ApdbUpdateRecord.from_json(row.update_time_ns, row.update_order, row.update_payload)
262 )
263 timer.add_values(row_count=len(records))
265 records.sort()
266 return records