Coverage for python / lsst / dax / apdb / cassandra / apdbCassandra.py: 8%

841 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-05 02:05 -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/>. 

21 

22from __future__ import annotations 

23 

24__all__ = ["ApdbCassandra"] 

25 

26import datetime 

27import json 

28import logging 

29import random 

30import uuid 

31import warnings 

32from collections import Counter, defaultdict 

33from collections.abc import Iterable, Iterator, Mapping, Set 

34from typing import TYPE_CHECKING, Any, cast 

35 

36import numpy as np 

37import pandas 

38 

39# If cassandra-driver is not there the module can still be imported 

40# but ApdbCassandra cannot be instantiated. 

41try: 

42 import cassandra 

43 import cassandra.query 

44 from cassandra.query import UNSET_VALUE 

45 

46 CASSANDRA_IMPORTED = True 

47except ImportError: 

48 CASSANDRA_IMPORTED = False 

49 

50import astropy.time 

51import felis.datamodel 

52 

53from lsst import sphgeom 

54from lsst.utils.iteration import chunk_iterable 

55 

56from ..apdb import Apdb, ApdbConfig 

57from ..apdbConfigFreezer import ApdbConfigFreezer 

58from ..apdbReplica import ApdbTableData, ReplicaChunk 

59from ..apdbSchema import ApdbSchema, ApdbTables 

60from ..apdbUpdateRecord import ( 

61 ApdbCloseDiaObjectValidityRecord, 

62 ApdbReassignDiaSourceToDiaObjectRecord, 

63 ApdbUpdateNDiaSourcesRecord, 

64) 

65from ..monitor import MonAgent 

66from ..recordIds import DiaObjectId, DiaSourceId 

67from ..schema_model import Table 

68from ..timer import Timer 

69from ..versionTuple import VersionTuple 

70from .apdbCassandraAdmin import ApdbCassandraAdmin 

71from .apdbCassandraReplica import ApdbCassandraReplica 

72from .apdbCassandraSchema import ApdbCassandraSchema, CreateTableOptions, ExtraTables 

73from .apdbMetadataCassandra import ApdbMetadataCassandra 

74from .cassandra_utils import ( 

75 ApdbCassandraTableData, 

76 execute_concurrent, 

77 literal, 

78 quote_id, 

79 select_concurrent, 

80) 

81from .config import ApdbCassandraConfig, ApdbCassandraConnectionConfig, ApdbCassandraTimePartitionRange 

82from .connectionContext import ConnectionContext, DbVersions 

83from .exceptions import CassandraMissingError 

84from .partitioner import Partitioner 

85from .sessionFactory import SessionContext, SessionFactory 

86 

87if TYPE_CHECKING: 

88 from ..apdbMetadata import ApdbMetadata 

89 from ..apdbUpdateRecord import ApdbUpdateRecord 

90 

91_LOG = logging.getLogger(__name__) 

92 

93_MON = MonAgent(__name__) 

94 

95VERSION = VersionTuple(1, 3, 0) 

96"""Version for the code controlling non-replication tables. This needs to be 

97updated following compatibility rules when schema produced by this code 

98changes. 

99""" 

100 

101 

102class ApdbCassandra(Apdb): 

103 """Implementation of APDB database with Apache Cassandra backend. 

104 

105 Parameters 

106 ---------- 

107 config : `ApdbCassandraConfig` 

108 Configuration object. 

109 """ 

110 

111 def __init__(self, config: ApdbCassandraConfig): 

112 if not CASSANDRA_IMPORTED: 

113 raise CassandraMissingError() 

114 

115 self._config = config 

116 self._keyspace = config.keyspace 

117 self._schema = ApdbSchema(config.schema_file, config.ss_schema_file) 

118 

119 self._session_factory = SessionFactory(config) 

120 self._connection_context: ConnectionContext | None = None 

121 

122 @property 

123 def _context(self) -> ConnectionContext: 

124 """Establish connection if not established and return context.""" 

125 if self._connection_context is None: 

126 current_versions = DbVersions( 

127 schema_version=self.schema.schemaVersion(), 

128 code_version=self.apdbImplementationVersion(), 

129 replica_version=ApdbCassandraReplica.apdbReplicaImplementationVersion(), 

130 ) 

131 _LOG.debug("Current versions: %s", current_versions) 

132 

133 session = self._session_factory.session() 

134 self._connection_context = ConnectionContext( 

135 session, self._config, self.schema.tableSchemas, current_versions 

136 ) 

137 

138 if _LOG.isEnabledFor(logging.DEBUG): 

139 _LOG.debug("ApdbCassandra Configuration: %s", self._connection_context.config.model_dump()) 

140 

141 return self._connection_context 

142 

143 def _timer(self, name: str, *, tags: Mapping[str, str | int] | None = None) -> Timer: 

144 """Create `Timer` instance given its name.""" 

145 return Timer(name, _MON, tags=tags) 

146 

147 @classmethod 

148 def apdbImplementationVersion(cls) -> VersionTuple: 

149 """Return version number for current APDB implementation. 

150 

151 Returns 

152 ------- 

153 version : `VersionTuple` 

154 Version of the code defined in implementation class. 

155 """ 

156 return VERSION 

157 

158 def getConfig(self) -> ApdbCassandraConfig: 

159 # docstring is inherited from a base class 

160 return self._context.config 

161 

162 def tableDef(self, table: ApdbTables) -> Table | None: 

163 # docstring is inherited from a base class 

164 return self.schema.tableSchemas.get(table) 

165 

166 @classmethod 

167 def init_database( 

168 cls, 

169 hosts: tuple[str, ...], 

170 keyspace: str, 

171 *, 

172 schema_file: str | None = None, 

173 ss_schema_file: str | None = None, 

174 read_sources_months: int | None = None, 

175 read_forced_sources_months: int | None = None, 

176 enable_replica: bool = False, 

177 replica_skips_diaobjects: bool = False, 

178 port: int | None = None, 

179 username: str | None = None, 

180 prefix: str | None = None, 

181 part_pixelization: str | None = None, 

182 part_pix_level: int | None = None, 

183 time_partition_tables: bool = True, 

184 time_partition_start: str | None = None, 

185 time_partition_end: str | None = None, 

186 read_consistency: str | None = None, 

187 write_consistency: str | None = None, 

188 read_timeout: int | None = None, 

189 write_timeout: int | None = None, 

190 ra_dec_columns: tuple[str, str] | None = None, 

191 replication_factor: int | None = None, 

192 drop: bool = False, 

193 table_options: CreateTableOptions | None = None, 

194 ) -> ApdbCassandraConfig: 

195 """Initialize new APDB instance and make configuration object for it. 

196 

197 Parameters 

198 ---------- 

199 hosts : `tuple` [`str`, ...] 

200 List of host names or IP addresses for Cassandra cluster. 

201 keyspace : `str` 

202 Name of the keyspace for APDB tables. 

203 schema_file : `str`, optional 

204 Location of (YAML) configuration file with APDB schema. If not 

205 specified then default location will be used. 

206 ss_schema_file : `str`, optional 

207 Location of (YAML) configuration file with SSO schema. If not 

208 specified then default location will be used. 

209 read_sources_months : `int`, optional 

210 Number of months of history to read from DiaSource. 

211 read_forced_sources_months : `int`, optional 

212 Number of months of history to read from DiaForcedSource. 

213 enable_replica : `bool`, optional 

214 If True, make additional tables used for replication to PPDB. 

215 replica_skips_diaobjects : `bool`, optional 

216 If `True` then do not fill regular ``DiaObject`` table when 

217 ``enable_replica`` is `True`. 

218 port : `int`, optional 

219 Port number to use for Cassandra connections. 

220 username : `str`, optional 

221 User name for Cassandra connections. 

222 prefix : `str`, optional 

223 Optional prefix for all table names. 

224 part_pixelization : `str`, optional 

225 Name of the MOC pixelization used for partitioning. 

226 part_pix_level : `int`, optional 

227 Pixelization level. 

228 time_partition_tables : `bool`, optional 

229 Create per-partition tables. 

230 time_partition_start : `str`, optional 

231 Starting time for per-partition tables, in yyyy-mm-ddThh:mm:ss 

232 format, in TAI. 

233 time_partition_end : `str`, optional 

234 Ending time for per-partition tables, in yyyy-mm-ddThh:mm:ss 

235 format, in TAI. 

236 read_consistency : `str`, optional 

237 Name of the consistency level for read operations. 

238 write_consistency : `str`, optional 

239 Name of the consistency level for write operations. 

240 read_timeout : `int`, optional 

241 Read timeout in seconds. 

242 write_timeout : `int`, optional 

243 Write timeout in seconds. 

244 ra_dec_columns : `tuple` [`str`, `str`], optional 

245 Names of ra/dec columns in DiaObject table. 

246 replication_factor : `int`, optional 

247 Replication factor used when creating new keyspace, if keyspace 

248 already exists its replication factor is not changed. 

249 drop : `bool`, optional 

250 If `True` then drop existing tables before re-creating the schema. 

251 table_options : `CreateTableOptions`, optional 

252 Options used when creating Cassandra tables. 

253 

254 Returns 

255 ------- 

256 config : `ApdbCassandraConfig` 

257 Resulting configuration object for a created APDB instance. 

258 """ 

259 # Some non-standard defaults for connection parameters, these can be 

260 # changed later in generated config. Check Cassandra driver 

261 # documentation for what these parameters do. These parameters are not 

262 # used during database initialization, but they will be saved with 

263 # generated config. 

264 connection_config = ApdbCassandraConnectionConfig( 

265 extra_parameters={ 

266 "idle_heartbeat_interval": 0, 

267 "idle_heartbeat_timeout": 30, 

268 "control_connection_timeout": 100, 

269 }, 

270 ) 

271 config = ApdbCassandraConfig( 

272 contact_points=hosts, 

273 keyspace=keyspace, 

274 enable_replica=enable_replica, 

275 replica_skips_diaobjects=replica_skips_diaobjects, 

276 connection_config=connection_config, 

277 ) 

278 config.partitioning.time_partition_tables = time_partition_tables 

279 if schema_file is not None: 

280 config.schema_file = schema_file 

281 if ss_schema_file is not None: 

282 config.ss_schema_file = ss_schema_file 

283 if read_sources_months is not None: 

284 config.read_sources_months = read_sources_months 

285 if read_forced_sources_months is not None: 

286 config.read_forced_sources_months = read_forced_sources_months 

287 if port is not None: 

288 config.connection_config.port = port 

289 if username is not None: 

290 config.connection_config.username = username 

291 if prefix is not None: 

292 config.prefix = prefix 

293 if part_pixelization is not None: 

294 config.partitioning.part_pixelization = part_pixelization 

295 if part_pix_level is not None: 

296 config.partitioning.part_pix_level = part_pix_level 

297 if time_partition_start is not None: 

298 config.partitioning.time_partition_start = time_partition_start 

299 if time_partition_end is not None: 

300 config.partitioning.time_partition_end = time_partition_end 

301 if read_consistency is not None: 

302 config.connection_config.read_consistency = read_consistency 

303 if write_consistency is not None: 

304 config.connection_config.write_consistency = write_consistency 

305 if read_timeout is not None: 

306 config.connection_config.read_timeout = read_timeout 

307 if write_timeout is not None: 

308 config.connection_config.write_timeout = write_timeout 

309 if ra_dec_columns is not None: 

310 config.ra_dec_columns = ra_dec_columns 

311 

312 cls._makeSchema(config, drop=drop, replication_factor=replication_factor, table_options=table_options) 

313 

314 return config 

315 

316 def get_replica(self) -> ApdbCassandraReplica: 

317 """Return `ApdbReplica` instance for this database.""" 

318 # Note that this instance has to stay alive while replica exists, so 

319 # we pass reference to self. 

320 return ApdbCassandraReplica(self) 

321 

322 @classmethod 

323 def _makeSchema( 

324 cls, 

325 config: ApdbConfig, 

326 *, 

327 drop: bool = False, 

328 replication_factor: int | None = None, 

329 table_options: CreateTableOptions | None = None, 

330 ) -> None: 

331 # docstring is inherited from a base class 

332 

333 if not isinstance(config, ApdbCassandraConfig): 

334 raise TypeError(f"Unexpected type of configuration object: {type(config)}") 

335 

336 simple_schema = ApdbSchema(config.schema_file, config.ss_schema_file) 

337 

338 with SessionContext(config) as session: 

339 schema = ApdbCassandraSchema( 

340 session=session, 

341 keyspace=config.keyspace, 

342 table_schemas=simple_schema.tableSchemas, 

343 prefix=config.prefix, 

344 time_partition_tables=config.partitioning.time_partition_tables, 

345 enable_replica=config.enable_replica, 

346 replica_skips_diaobjects=config.replica_skips_diaobjects, 

347 ) 

348 

349 # Ask schema to create all tables. 

350 part_range_config: ApdbCassandraTimePartitionRange | None = None 

351 if config.partitioning.time_partition_tables: 

352 partitioner = Partitioner(config) 

353 time_partition_start = astropy.time.Time( 

354 config.partitioning.time_partition_start, format="isot", scale="tai" 

355 ) 

356 time_partition_end = astropy.time.Time( 

357 config.partitioning.time_partition_end, format="isot", scale="tai" 

358 ) 

359 part_range_config = ApdbCassandraTimePartitionRange( 

360 start=partitioner.time_partition(time_partition_start), 

361 end=partitioner.time_partition(time_partition_end), 

362 ) 

363 schema.makeSchema( 

364 drop=drop, 

365 part_range=part_range_config, 

366 replication_factor=replication_factor, 

367 table_options=table_options, 

368 ) 

369 else: 

370 schema.makeSchema( 

371 drop=drop, replication_factor=replication_factor, table_options=table_options 

372 ) 

373 

374 meta_table_name = ApdbTables.metadata.table_name(config.prefix) 

375 metadata = ApdbMetadataCassandra( 

376 session, meta_table_name, config.keyspace, "read_tuples", "write" 

377 ) 

378 

379 # Fill version numbers, overrides if they existed before. 

380 metadata.set( 

381 ConnectionContext.metadataSchemaVersionKey, str(simple_schema.schemaVersion()), force=True 

382 ) 

383 metadata.set( 

384 ConnectionContext.metadataCodeVersionKey, str(cls.apdbImplementationVersion()), force=True 

385 ) 

386 

387 if config.enable_replica: 

388 # Only store replica code version if replica is enabled. 

389 metadata.set( 

390 ConnectionContext.metadataReplicaVersionKey, 

391 str(ApdbCassandraReplica.apdbReplicaImplementationVersion()), 

392 force=True, 

393 ) 

394 

395 # Store frozen part of a configuration in metadata. 

396 freezer = ApdbConfigFreezer[ApdbCassandraConfig](ConnectionContext.frozen_parameters) 

397 metadata.set(ConnectionContext.metadataConfigKey, freezer.to_json(config), force=True) 

398 

399 # Store time partition range. 

400 if part_range_config: 

401 part_range_config.save_to_meta(metadata) 

402 

403 def getDiaObjects(self, region: sphgeom.Region) -> pandas.DataFrame: 

404 # docstring is inherited from a base class 

405 context = self._context 

406 config = context.config 

407 

408 sp_where, num_sp_part = context.partitioner.spatial_where(region, for_prepare=True) 

409 _LOG.debug("getDiaObjects: #partitions: %s", len(sp_where)) 

410 

411 # We need to exclude extra partitioning columns from result. 

412 column_names = context.schema.apdbColumnNames(ApdbTables.DiaObjectLast) 

413 what = ",".join(quote_id(column) for column in column_names) 

414 

415 table_name = context.schema.tableName(ApdbTables.DiaObjectLast) 

416 query = f'SELECT {what} from "{self._keyspace}"."{table_name}"' 

417 statements: list[tuple] = [] 

418 for where, params in sp_where: 

419 full_query = f"{query} WHERE {where}" 

420 if params: 

421 statement = context.preparer.prepare(full_query) 

422 else: 

423 # If there are no params then it is likely that query has a 

424 # bunch of literals rendered already, no point trying to 

425 # prepare it because it's not reusable. 

426 statement = cassandra.query.SimpleStatement(full_query) 

427 statements.append((statement, params)) 

428 _LOG.debug("getDiaObjects: #queries: %s", len(statements)) 

429 

430 with self._timer("select_time", tags={"table": "DiaObject", "method": "getDiaObjects"}) as timer: 

431 raw_objects = cast( 

432 ApdbCassandraTableData, 

433 select_concurrent( 

434 context.session, 

435 statements, 

436 "read_raw_multi", 

437 config.connection_config.read_concurrency, 

438 ), 

439 ) 

440 objects = raw_objects.to_pandas(context.schema._table_schema(ApdbTables.DiaObjectLast)) 

441 timer.add_values(row_count=len(objects), num_sp_part=num_sp_part, num_queries=len(statements)) 

442 

443 _LOG.debug("found %s DiaObjects", objects.shape[0]) 

444 return objects 

445 

446 def getDiaSources( 

447 self, 

448 region: sphgeom.Region, 

449 object_ids: Iterable[int] | None, 

450 visit_time: astropy.time.Time, 

451 start_time: astropy.time.Time | None = None, 

452 ) -> pandas.DataFrame | None: 

453 # docstring is inherited from a base class 

454 context = self._context 

455 config = context.config 

456 

457 months = config.read_sources_months 

458 if start_time is None and months == 0: 

459 return None 

460 

461 mjd_end = float(visit_time.tai.mjd) 

462 if start_time is None: 

463 mjd_start = mjd_end - months * 30 

464 else: 

465 mjd_start = float(start_time.tai.mjd) 

466 

467 return self._getSources(region, object_ids, mjd_start, mjd_end, ApdbTables.DiaSource) 

468 

469 def getDiaForcedSources( 

470 self, 

471 region: sphgeom.Region, 

472 object_ids: Iterable[int] | None, 

473 visit_time: astropy.time.Time, 

474 start_time: astropy.time.Time | None = None, 

475 ) -> pandas.DataFrame | None: 

476 # docstring is inherited from a base class 

477 context = self._context 

478 config = context.config 

479 

480 months = config.read_forced_sources_months 

481 if start_time is None and months == 0: 

482 return None 

483 

484 mjd_end = float(visit_time.tai.mjd) 

485 if start_time is None: 

486 mjd_start = mjd_end - months * 30 

487 else: 

488 mjd_start = float(start_time.tai.mjd) 

489 

490 return self._getSources(region, object_ids, mjd_start, mjd_end, ApdbTables.DiaForcedSource) 

491 

492 def getDiaObjectsForDedup(self, since: astropy.time.Time | None = None) -> pandas.DataFrame: 

493 # docstring is inherited from a base class 

494 context = self._context 

495 config = context.config 

496 

497 if not context.has_dedup_table: 

498 raise TypeError("DiaObjectDedup table does not exist in this APDB instance.") 

499 

500 if since is None: 

501 # Read last deduplication time from metadata. 

502 dedup_str = context.metadata.get(context.metadataDedupKey) 

503 if dedup_str is not None: 

504 dedup_state = json.loads(dedup_str) 

505 dedup_time_str = dedup_state["dedup_time_iso_tai"] 

506 since = astropy.time.Time(dedup_time_str, format="iso", scale="tai") 

507 

508 column_names = context.schema.apdbColumnNames(ExtraTables.DiaObjectDedup) 

509 what = ",".join(quote_id(column) for column in column_names) 

510 

511 validity_start_column = self._timestamp_column_name("validityStart") 

512 timestamp = None if since is None else self._timestamp_column_value(since) 

513 

514 table_name = context.schema.tableName(ExtraTables.DiaObjectDedup) 

515 query = f'SELECT {what} FROM "{self._keyspace}"."{table_name}" WHERE dedup_part = %s' 

516 if since is not None: 

517 query += f' AND "{validity_start_column}" >= %s' 

518 query += " ALLOW FILTERING" 

519 

520 statement = cassandra.query.SimpleStatement(query) 

521 

522 num_part = config.partitioning.num_part_dedup 

523 statements = [] 

524 for dedup_part in range(num_part): 

525 params = (dedup_part,) if timestamp is None else (dedup_part, timestamp) 

526 statements.append((statement, params)) 

527 

528 with self._timer( 

529 "select_time", tags={"table": "DiaObjectDedup", "method": "getDiaObjectsForDedup"} 

530 ) as timer: 

531 objects_raw = cast( 

532 ApdbCassandraTableData, 

533 select_concurrent( 

534 context.session, 

535 statements, 

536 "read_raw_multi_dedup", 

537 config.connection_config.read_concurrency, 

538 ), 

539 ) 

540 objects = objects_raw.to_pandas(context.schema._table_schema(ExtraTables.DiaObjectDedup)) 

541 timer.add_values(row_count=len(objects), num_queries=num_part) 

542 

543 _LOG.debug("found %s DiaObjectDedup records", objects.shape[0]) 

544 return objects 

545 

546 def getDiaSourcesForDiaObjects( 

547 self, objects: list[DiaObjectId], start_time: astropy.time.Time, max_dist_arcsec: float = 1.0 

548 ) -> pandas.DataFrame: 

549 # docstring is inherited from a base class 

550 context = self._context 

551 config = context.config 

552 

553 # Which tables to query and temporal constraints. 

554 end_time = self._current_time() 

555 tables, temporal_where = context.partitioner.temporal_where( 

556 ApdbTables.DiaSource, 

557 start_time, 

558 end_time, 

559 for_prepare=False, 

560 partitons_range=context.time_partitions_range, 

561 query_per_time_part=False, 

562 ) 

563 if not tables: 

564 warnings.warn( 

565 f"Query time range ({start_time.isot} - {end_time.isot}) does not overlap database " 

566 "time partitions." 

567 ) 

568 

569 # Group DiaObjects by partition. 

570 partitioned_object_ids = self._group_dia_objects_by_partition( 

571 context.partitioner, objects, max_dist_arcsec 

572 ) 

573 

574 # Columns to return. 

575 column_names = context.schema.apdbColumnNames(ApdbTables.DiaSource) 

576 what = ",".join(quote_id(column) for column in column_names) 

577 

578 # Make a bunch of queries. 

579 statements = [] 

580 suffix = "ALLOW FILTERING" 

581 for apdb_part, diaObjectIds in partitioned_object_ids.items(): 

582 spatial_where = [(f"apdb_part = {apdb_part}", ())] 

583 for table in tables: 

584 prefix = f'SELECT {what} FROM "{self._keyspace}"."{table}"' 

585 for id_chunk in chunk_iterable(diaObjectIds, 10_000): 

586 chunk_str = ",".join(str(diaObjectId) for diaObjectId in id_chunk) 

587 id_where = (f'"diaObjectId" IN ({chunk_str})', ()) 

588 statements += list( 

589 self._combine_where(prefix, spatial_where, temporal_where, id_where, suffix=suffix) 

590 ) 

591 

592 _LOG.debug("getDiaSourcesForDiaObjects #queries: %s", len(statements)) 

593 

594 with self._timer( 

595 "select_time", tags={"table": "DiaSource", "method": "getDiaSourcesForDiaObjects"} 

596 ) as timer: 

597 table_data_raw = cast( 

598 ApdbCassandraTableData, 

599 select_concurrent( 

600 context.session, 

601 statements, 

602 "read_raw_multi", 

603 config.connection_config.read_concurrency, 

604 ), 

605 ) 

606 catalog = table_data_raw.to_pandas(context.schema._table_schema(ApdbTables.DiaSource)) 

607 timer.add_values(row_count_from_db=len(catalog), num_queries=len(statements)) 

608 

609 # precise filtering on midpointMjdTai 

610 catalog = cast(pandas.DataFrame, catalog[catalog["midpointMjdTai"] >= start_time.tai.mjd]) 

611 

612 timer.add_values(row_count=len(catalog)) 

613 

614 _LOG.debug("found %d DiaSources", len(catalog)) 

615 return catalog 

616 

617 def containsVisitDetector( 

618 self, 

619 visit: int, 

620 detector: int, 

621 region: sphgeom.Region, 

622 visit_time: astropy.time.Time, 

623 ) -> bool: 

624 # docstring is inherited from a base class 

625 context = self._context 

626 

627 table_name = context.schema.tableName(ExtraTables.ApdbVisitDetector) 

628 query = f'SELECT count(*) FROM "{self._keyspace}"."{table_name}" WHERE visit = %s AND detector = %s' 

629 with self._timer("contains_visit_detector_time", tags={"table": table_name}): 

630 result = context.session.execute(query, (visit, detector)) 

631 return bool(result.one()[0]) 

632 

633 def store( 

634 self, 

635 visit_time: astropy.time.Time, 

636 objects: pandas.DataFrame, 

637 sources: pandas.DataFrame | None = None, 

638 forced_sources: pandas.DataFrame | None = None, 

639 ) -> None: 

640 # docstring is inherited from a base class 

641 context = self._context 

642 config = context.config 

643 

644 # Store visit/detector in a special table, this has to be done 

645 # before all other writes so if there is a failure at any point 

646 # later we still have a record for attempted write. 

647 visit_detector: set[tuple[int, int]] = set() 

648 for df in sources, forced_sources: 

649 if df is not None and not df.empty: 

650 df = df[["visit", "detector"]] 

651 for visit, detector in df.itertuples(index=False): 

652 visit_detector.add((visit, detector)) 

653 

654 if visit_detector: 

655 # Typically there is only one entry, do not bother with 

656 # concurrency. 

657 table_name = context.schema.tableName(ExtraTables.ApdbVisitDetector) 

658 query = f'INSERT INTO "{self._keyspace}"."{table_name}" (visit, detector) VALUES (%s, %s)' 

659 for item in visit_detector: 

660 context.session.execute(query, item, execution_profile="write") 

661 

662 objects = self._fix_input_timestamps(objects) 

663 if sources is not None: 

664 sources = self._fix_input_timestamps(sources) 

665 if forced_sources is not None: 

666 forced_sources = self._fix_input_timestamps(forced_sources) 

667 

668 replica_chunk: ReplicaChunk | None = None 

669 if context.schema.replication_enabled: 

670 replica_chunk = ReplicaChunk.make_replica_chunk(visit_time, config.replica_chunk_seconds) 

671 self._storeReplicaChunk(replica_chunk) 

672 

673 # fill region partition column for DiaObjects 

674 objects = self._add_apdb_part(objects) 

675 self._storeDiaObjects(objects, visit_time, replica_chunk) 

676 

677 if sources is not None and len(sources) > 0: 

678 # copy apdb_part column from DiaObjects to DiaSources 

679 sources = self._add_apdb_part(sources) 

680 subchunk = self._storeDiaSources(ApdbTables.DiaSource, sources, replica_chunk) 

681 self._storeDiaSourcesPartitions(sources, visit_time, replica_chunk, subchunk) 

682 

683 if forced_sources is not None and len(forced_sources) > 0: 

684 forced_sources = self._add_apdb_part(forced_sources) 

685 self._storeDiaSources(ApdbTables.DiaForcedSource, forced_sources, replica_chunk) 

686 

687 def reassignDiaSourcesToDiaObjects( 

688 self, 

689 idMap: Mapping[DiaSourceId, int], 

690 *, 

691 increment_nDiaSources: bool = True, 

692 decrement_nDiaSources: bool = True, 

693 ) -> None: 

694 # docstring is inherited from a base class 

695 context = self._context 

696 config = context.config 

697 

698 source_ids = {source_id.diaSourceId for source_id in idMap} 

699 

700 # Find all DiaSources. 

701 found_sources = self._get_diasource_data( 

702 idMap, "apdb_part", "diaObjectId", "ra", "dec", "midpointMjdTai" 

703 ) 

704 

705 if missing_ids := (source_ids - {row.diaSourceId for row in found_sources}): 

706 raise LookupError(f"Some source IDs were not found in DiaSource table: {missing_ids}") 

707 

708 found_sources_by_id = {row.diaSourceId: row for row in found_sources} 

709 

710 # Make sure that all DiaObjects exist, we also want to know 

711 # nDiaSources count for current and new records because we want to 

712 # send updated values to replica. 

713 current_object_ids = { 

714 DiaObjectId(diaObjectId=row.diaObjectId, ra=row.ra, dec=row.dec) for row in found_sources 

715 } 

716 # Assume that DiaSource ra/dec are very close to re-assigned objects. 

717 new_object_ids = { 

718 DiaObjectId(diaObjectId=diaObjectId, ra=source_id.ra, dec=source_id.dec) 

719 for source_id, diaObjectId in idMap.items() 

720 } 

721 all_object_ids = new_object_ids | current_object_ids 

722 found_objects = self._get_diaobject_data(all_object_ids, "apdb_part", "ra", "dec", "nDiaSources") 

723 

724 if missing_ids := ( 

725 {row.diaObjectId for row in all_object_ids} - {row.diaObjectId for row in found_objects} 

726 ): 

727 raise LookupError(f"Some object IDs were not found in DiaObjectLast table: {missing_ids}") 

728 

729 update_records: list[ApdbUpdateRecord] = [] 

730 update_order = 0 

731 current_time = self._current_time() 

732 current_time_ns = int(current_time.unix_tai * 1e9) 

733 

734 # Update DiaSources. 

735 table_name = context.schema.tableName(ApdbTables.DiaSource) 

736 statements: list[tuple] = [] 

737 for source_id, diaObjectId in idMap.items(): 

738 source_row = found_sources_by_id[source_id.diaSourceId] 

739 apdb_part = source_row.apdb_part 

740 time_part = context.partitioner.time_partition(source_row.midpointMjdTai) 

741 

742 if config.partitioning.time_partition_tables: 

743 statement = context.preparer.prepare( 

744 f'UPDATE "{self._keyspace}"."{table_name}_{time_part}" SET "diaObjectId" = ? ' 

745 'WHERE apdb_part = ? AND "diaSourceId" = ?' 

746 ) 

747 statements.append((statement, (diaObjectId, apdb_part, source_id.diaSourceId))) 

748 else: 

749 statement = context.preparer.prepare( 

750 f'UPDATE "{self._keyspace}"."{table_name}" SET "diaObjectId" = ? ' 

751 'WHERE apdb_part = ? AND apdb_time_part = ? AND "diaSourceId" = ?' 

752 ) 

753 statements.append((statement, (diaObjectId, apdb_part, time_part, source_id.diaSourceId))) 

754 

755 if context.schema.replication_enabled: 

756 update_records.append( 

757 ApdbReassignDiaSourceToDiaObjectRecord( 

758 diaSourceId=source_id.diaSourceId, 

759 ra=source_id.ra, 

760 dec=source_id.dec, 

761 midpointMjdTai=source_id.midpointMjdTai, 

762 diaObjectId=diaObjectId, 

763 update_time_ns=current_time_ns, 

764 update_order=update_order, 

765 ) 

766 ) 

767 update_order += 1 

768 

769 with self._timer( 

770 "update_time", tags={"table": table_name, "method": "reassignDiaSourcesToDiaObjects"} 

771 ) as timer: 

772 execute_concurrent(context.session, statements, execution_profile="write") 

773 timer.add_values(num_queries=len(statements)) 

774 

775 # Update nDiaSources in DiaObjectLast. We do not update DiaObject table 

776 # here because it may not even exist. PPDB updates DiaObject from 

777 # update records. 

778 if increment_nDiaSources or decrement_nDiaSources: 

779 table_name = context.schema.tableName(ApdbTables.DiaObjectLast) 

780 statement = context.preparer.prepare( 

781 f'UPDATE "{self._keyspace}"."{table_name}" SET "nDiaSources" = ? ' 

782 'WHERE apdb_part = ? AND "diaObjectId" = ?' 

783 ) 

784 statements = [] 

785 

786 # Calculate increments/decrements for all affected DiaObjects. 

787 increments: Counter = Counter() 

788 if increment_nDiaSources: 

789 increments.update(idMap.values()) 

790 if decrement_nDiaSources: 

791 increments.subtract(row.diaObjectId for row in found_sources) 

792 

793 for row in found_objects: 

794 if increments.get(row.diaObjectId): 

795 nDiaSources = row.nDiaSources + increments[row.diaObjectId] 

796 statements.append((statement, (nDiaSources, row.apdb_part, row.diaObjectId))) 

797 

798 # Also send updated values to replica. 

799 if context.schema.replication_enabled: 

800 update_records.append( 

801 ApdbUpdateNDiaSourcesRecord( 

802 diaObjectId=row.diaObjectId, 

803 ra=row.ra, 

804 dec=row.dec, 

805 nDiaSources=nDiaSources, 

806 update_time_ns=current_time_ns, 

807 update_order=update_order, 

808 ) 

809 ) 

810 update_order += 1 

811 

812 if statements: 

813 with self._timer( 

814 "update_time", tags={"table": table_name, "method": "reassignDiaSourcesToDiaObjects"} 

815 ) as timer: 

816 execute_concurrent(context.session, statements, execution_profile="write") 

817 timer.add_values(num_queries=len(statements)) 

818 

819 if update_records: 

820 replica_chunk = ReplicaChunk.make_replica_chunk(current_time, config.replica_chunk_seconds) 

821 self._storeUpdateRecords(update_records, replica_chunk, store_chunk=True) 

822 

823 def setValidityEnd( 

824 self, objects: list[DiaObjectId], validityEnd: astropy.time.Time, raise_on_missing_id: bool = False 

825 ) -> int: 

826 # docstring is inherited from a base class 

827 if not objects: 

828 return 0 

829 

830 context = self._context 

831 config = context.config 

832 

833 pad_arcsec = 1.0 

834 partitioned_object_ids = self._group_dia_objects_by_partition( 

835 context.partitioner, objects, pad_arcsec 

836 ) 

837 

838 # Check that all objects exist. 

839 table_name = context.schema.tableName(ApdbTables.DiaObjectLast) 

840 statements: list[tuple] = [] 

841 for apdb_part, diaObjectIds in partitioned_object_ids.items(): 

842 id_str = ",".join(str(diaObjectId) for diaObjectId in diaObjectIds) 

843 query = ( 

844 f'SELECT apdb_part, "diaObjectId" FROM "{self._keyspace}"."{table_name}" ' 

845 f'WHERE apdb_part = %s AND "diaObjectId" IN ({id_str})' 

846 ) 

847 statement = cassandra.query.SimpleStatement(query) 

848 statements.append((statement, (apdb_part,))) 

849 

850 with self._timer("select_time", tags={"table": table_name, "method": "setValidityEnd"}) as timer: 

851 records = cast( 

852 list[tuple[int, int]], 

853 select_concurrent( 

854 context.session, 

855 statements, 

856 "read_tuples", 

857 config.connection_config.read_concurrency, 

858 ), 

859 ) 

860 timer.add_values(row_count=len(objects)) 

861 

862 requested_ids = {obj.diaObjectId for obj in objects} 

863 found_ids = {rec[1] for rec in records} 

864 if extra_ids := (found_ids - requested_ids): 

865 raise RuntimeError(f"Consistency error - found duplicate records for object IDs: {extra_ids}") 

866 if raise_on_missing_id: 

867 if missing_ids := (requested_ids - found_ids): 

868 raise LookupError(f"Some object IDs are missing from DiaObjectLast table: {missing_ids}") 

869 

870 # Filter existing records. 

871 if len(objects) != len(found_ids): 

872 objects = [obj for obj in objects if obj.diaObjectId in found_ids] 

873 

874 if not objects: 

875 return 0 

876 

877 # Group by partitions again. 

878 grouped_object_ids: dict[int, list[int]] = defaultdict(list) 

879 for apdb_part, diaObjectId in records: 

880 grouped_object_ids[apdb_part].append(diaObjectId) 

881 

882 # Remove all matching rows from DiaObjectLast. 

883 statements = [] 

884 for apdb_part, diaObjectIds in grouped_object_ids.items(): 

885 id_str = ",".join(str(diaObjectId) for diaObjectId in diaObjectIds) 

886 query = ( 

887 f'DELETE FROM "{self._keyspace}"."{table_name}" ' 

888 f'WHERE apdb_part = %s AND "diaObjectId" IN ({id_str})' 

889 ) 

890 statement = cassandra.query.SimpleStatement(query) 

891 statements.append((statement, (apdb_part,))) 

892 

893 # Also remove from DiaObjectLastToPartition. 

894 reverse_table_name = context.schema.tableName(ExtraTables.DiaObjectLastToPartition) 

895 id_str = ",".join(str(diaObjectId) for _, diaObjectId in records) 

896 query = f'DELETE FROM "{self._keyspace}"."{reverse_table_name}" WHERE "diaObjectId" IN ({id_str})' 

897 statement = cassandra.query.SimpleStatement(query) 

898 statements.append((statement, ())) 

899 

900 with self._timer("delete_time", tags={"table": table_name, "method": "setValidityEnd"}) as timer: 

901 execute_concurrent(context.session, statements, execution_profile="write") 

902 timer.add_values(row_count=len(records)) 

903 

904 # If repication is enabled then send all updates. 

905 if context.schema.replication_enabled: 

906 current_time = self._current_time() 

907 current_time_ns = int(current_time.unix_tai * 1e9) 

908 replica_chunk = ReplicaChunk.make_replica_chunk(current_time, config.replica_chunk_seconds) 

909 

910 update_records = [ 

911 ApdbCloseDiaObjectValidityRecord( 

912 diaObjectId=obj.diaObjectId, 

913 ra=obj.ra, 

914 dec=obj.dec, 

915 update_time_ns=current_time_ns, 

916 update_order=index, 

917 validityEndMjdTai=float(validityEnd.tai.mjd), 

918 nDiaSources=None, 

919 ) 

920 for index, obj in enumerate(objects) 

921 ] 

922 

923 self._storeUpdateRecords(update_records, replica_chunk, store_chunk=True) 

924 

925 return len(objects) 

926 

927 def resetDedup(self, dedup_time: astropy.time.Time | None = None) -> None: 

928 # docstring is inherited from a base class 

929 context = self._context 

930 

931 if not context.has_dedup_table: 

932 raise TypeError("DiaObjectDedup table does not exist in this APDB instance.") 

933 

934 if dedup_time is None: 

935 dedup_time = self._current_time() 

936 

937 validity_start_column = self._timestamp_column_name("validityStart") 

938 

939 # Find latest timestamp in deduplication table. 

940 table_name = context.schema.tableName(ExtraTables.DiaObjectDedup) 

941 query = f'SELECT MAX("{validity_start_column}") FROM "{self._keyspace}"."{table_name}"' 

942 result = context.session.execute(query, execution_profile="read_tuples") 

943 max_value = result.one()[0] 

944 if self._schema.has_mjd_timestamps: 

945 max_validity_start = astropy.time.Time(max_value, format="mjd", scale="tai") 

946 else: 

947 max_validity_start = astropy.time.Time(max_value, format="datetime", scale="tai") 

948 

949 # If max time is lower than dedup time we can do TRUNCATE. 

950 if dedup_time >= max_validity_start: 

951 query = f'TRUNCATE TABLE "{self._keyspace}"."{table_name}"' 

952 context.session.execute(query, execution_profile="write") 

953 else: 

954 dedup_time_value = self._timestamp_column_value(dedup_time) 

955 query = f'DELETE FROM "{self._keyspace}"."{table_name}" WHERE "{validity_start_column}" < %s' 

956 context.session.execute(query, (dedup_time_value,), execution_profile="write") 

957 

958 # Store dedup time. 

959 data = {"dedup_time_iso_tai": dedup_time.tai.to_value("iso")} 

960 data_json = json.dumps(data) 

961 context.metadata.set(context.metadataDedupKey, data_json, force=True) 

962 

963 def reassignDiaSources(self, idMap: Mapping[int, int]) -> None: 

964 # docstring is inherited from a base class 

965 context = self._context 

966 config = context.config 

967 

968 now = self._current_time() 

969 reassign_time_column = self._timestamp_column_name("ssObjectReassocTime") 

970 reassignTime = self._timestamp_column_value(now) 

971 

972 # To update a record we need to know its exact primary key (including 

973 # partition key) so we start by querying for diaSourceId to find the 

974 # primary keys. 

975 

976 table_name = context.schema.tableName(ExtraTables.DiaSourceToPartition) 

977 # split it into 1k IDs per query 

978 selects: list[tuple] = [] 

979 for ids in chunk_iterable(idMap.keys(), 1_000): 

980 ids_str = ",".join(str(item) for item in ids) 

981 selects.append( 

982 ( 

983 ( 

984 'SELECT "diaSourceId", "apdb_part", "apdb_time_part", "apdb_replica_chunk" ' 

985 f'FROM "{self._keyspace}"."{table_name}" WHERE "diaSourceId" IN ({ids_str})' 

986 ), 

987 {}, 

988 ) 

989 ) 

990 

991 # No need for DataFrame here, read data as tuples. 

992 result = cast( 

993 list[tuple[int, int, int, int | None]], 

994 select_concurrent( 

995 context.session, selects, "read_tuples", config.connection_config.read_concurrency 

996 ), 

997 ) 

998 

999 # Make mapping from source ID to its partition. 

1000 id2partitions: dict[int, tuple[int, int]] = {} 

1001 id2chunk_id: dict[int, int] = {} 

1002 for row in result: 

1003 id2partitions[row[0]] = row[1:3] 

1004 if row[3] is not None: 

1005 id2chunk_id[row[0]] = row[3] 

1006 

1007 # make sure we know partitions for each ID 

1008 if set(id2partitions) != set(idMap): 

1009 missing = ",".join(str(item) for item in set(idMap) - set(id2partitions)) 

1010 raise ValueError(f"Following DiaSource IDs do not exist in the database: {missing}") 

1011 

1012 # Reassign in standard tables 

1013 queries: list[tuple[cassandra.query.PreparedStatement, tuple]] = [] 

1014 for diaSourceId, ssObjectId in idMap.items(): 

1015 apdb_part, apdb_time_part = id2partitions[diaSourceId] 

1016 values: tuple 

1017 if config.partitioning.time_partition_tables: 

1018 table_name = context.schema.tableName(ApdbTables.DiaSource, apdb_time_part) 

1019 query = ( 

1020 f'UPDATE "{self._keyspace}"."{table_name}"' 

1021 f' SET "ssObjectId" = ?, "diaObjectId" = NULL, "{reassign_time_column}" = ?' 

1022 ' WHERE "apdb_part" = ? AND "diaSourceId" = ?' 

1023 ) 

1024 values = (ssObjectId, reassignTime, apdb_part, diaSourceId) 

1025 else: 

1026 table_name = context.schema.tableName(ApdbTables.DiaSource) 

1027 query = ( 

1028 f'UPDATE "{self._keyspace}"."{table_name}"' 

1029 f' SET "ssObjectId" = ?, "diaObjectId" = NULL, "{reassign_time_column}" = ?' 

1030 ' WHERE "apdb_part" = ? AND "apdb_time_part" = ? AND "diaSourceId" = ?' 

1031 ) 

1032 values = (ssObjectId, reassignTime, apdb_part, apdb_time_part, diaSourceId) 

1033 queries.append((context.preparer.prepare(query), values)) 

1034 

1035 # TODO: (DM-50190) Replication for updated records is not implemented. 

1036 if id2chunk_id: 

1037 warnings.warn("Replication of reassigned DiaSource records is not implemented.", stacklevel=2) 

1038 

1039 _LOG.debug("%s: will update %d records", table_name, len(idMap)) 

1040 with self._timer("source_reassign_time") as timer: 

1041 execute_concurrent(context.session, queries, execution_profile="write") 

1042 timer.add_values(source_count=len(idMap)) 

1043 

1044 def countUnassociatedObjects(self) -> int: 

1045 # docstring is inherited from a base class 

1046 

1047 # It's too inefficient to implement it for Cassandra in current schema. 

1048 raise NotImplementedError() 

1049 

1050 @property 

1051 def schema(self) -> ApdbSchema: 

1052 # docstring is inherited from a base class 

1053 return self._schema 

1054 

1055 @property 

1056 def metadata(self) -> ApdbMetadata: 

1057 # docstring is inherited from a base class 

1058 context = self._context 

1059 return context.metadata 

1060 

1061 @property 

1062 def admin(self) -> ApdbCassandraAdmin: 

1063 # docstring is inherited from a base class 

1064 return ApdbCassandraAdmin(self) 

1065 

1066 def _getSources( 

1067 self, 

1068 region: sphgeom.Region, 

1069 object_ids: Iterable[int] | None, 

1070 mjd_start: float, 

1071 mjd_end: float, 

1072 table_name: ApdbTables, 

1073 ) -> pandas.DataFrame: 

1074 """Return catalog of DiaSource instances given set of DiaObject IDs. 

1075 

1076 Parameters 

1077 ---------- 

1078 region : `lsst.sphgeom.Region` 

1079 Spherical region. 

1080 object_ids : 

1081 Collection of DiaObject IDs 

1082 mjd_start : `float` 

1083 Lower bound of time interval. 

1084 mjd_end : `float` 

1085 Upper bound of time interval. 

1086 table_name : `ApdbTables` 

1087 Name of the table. 

1088 

1089 Returns 

1090 ------- 

1091 catalog : `pandas.DataFrame`, or `None` 

1092 Catalog containing DiaSource records. Empty catalog is returned if 

1093 ``object_ids`` is empty. 

1094 """ 

1095 context = self._context 

1096 config = context.config 

1097 

1098 object_id_set: Set[int] = set() 

1099 if object_ids is not None: 

1100 object_id_set = set(object_ids) 

1101 if len(object_id_set) == 0: 

1102 return self._make_empty_catalog(table_name) 

1103 

1104 sp_where, num_sp_part = context.partitioner.spatial_where(region, for_prepare=True) 

1105 tables, temporal_where = context.partitioner.temporal_where( 

1106 table_name, mjd_start, mjd_end, for_prepare=True, partitons_range=context.time_partitions_range 

1107 ) 

1108 if not tables: 

1109 start = astropy.time.Time(mjd_start, format="mjd", scale="tai") 

1110 end = astropy.time.Time(mjd_end, format="mjd", scale="tai") 

1111 warnings.warn( 

1112 f"Query time range ({start.isot} - {end.isot}) does not overlap database time partitions." 

1113 ) 

1114 

1115 # We need to exclude extra partitioning columns from result. 

1116 column_names = context.schema.apdbColumnNames(table_name) 

1117 what = ",".join(quote_id(column) for column in column_names) 

1118 

1119 # Build all queries 

1120 statements: list[tuple] = [] 

1121 for table in tables: 

1122 prefix = f'SELECT {what} from "{self._keyspace}"."{table}"' 

1123 statements += list(self._combine_where(prefix, sp_where, temporal_where)) 

1124 _LOG.debug("_getSources %s: #queries: %s", table_name, len(statements)) 

1125 

1126 with self._timer("select_time", tags={"table": table_name.name, "method": "_getSources"}) as timer: 

1127 table_data_raw = cast( 

1128 ApdbCassandraTableData, 

1129 select_concurrent( 

1130 context.session, 

1131 statements, 

1132 "read_raw_multi", 

1133 config.connection_config.read_concurrency, 

1134 ), 

1135 ) 

1136 catalog = table_data_raw.to_pandas(context.schema._table_schema(table_name)) 

1137 timer.add_values( 

1138 row_count_from_db=len(catalog), num_sp_part=num_sp_part, num_queries=len(statements) 

1139 ) 

1140 

1141 # filter by given object IDs 

1142 if len(object_id_set) > 0: 

1143 catalog = cast(pandas.DataFrame, catalog[catalog["diaObjectId"].isin(object_id_set)]) 

1144 

1145 # precise filtering on midpointMjdTai 

1146 catalog = cast(pandas.DataFrame, catalog[catalog["midpointMjdTai"] > mjd_start]) 

1147 

1148 timer.add_values(row_count=len(catalog)) 

1149 

1150 _LOG.debug("found %d %ss", catalog.shape[0], table_name.name) 

1151 return catalog 

1152 

1153 def _storeReplicaChunk(self, replica_chunk: ReplicaChunk) -> None: 

1154 context = self._context 

1155 config = context.config 

1156 

1157 # Cassandra timestamp uses milliseconds since epoch 

1158 timestamp = int(replica_chunk.last_update_time.unix_tai * 1000) 

1159 

1160 # everything goes into a single partition 

1161 partition = 0 

1162 

1163 table_name = context.schema.tableName(ExtraTables.ApdbReplicaChunks) 

1164 

1165 columns = ["partition", "apdb_replica_chunk", "last_update_time", "unique_id"] 

1166 values = [partition, replica_chunk.id, timestamp, replica_chunk.unique_id] 

1167 if context.has_chunk_sub_partitions: 

1168 columns.append("has_subchunks") 

1169 values.append(True) 

1170 

1171 column_list = ", ".join(columns) 

1172 placeholders = ",".join(["%s"] * len(columns)) 

1173 query = f'INSERT INTO "{self._keyspace}"."{table_name}" ({column_list}) VALUES ({placeholders})' 

1174 

1175 context.session.execute( 

1176 query, 

1177 values, 

1178 timeout=config.connection_config.write_timeout, 

1179 execution_profile="write", 

1180 ) 

1181 

1182 def _queryDiaObjectLastPartitions(self, ids: Iterable[int]) -> Mapping[int, int]: 

1183 """Return existing mapping of diaObjectId to its last partition.""" 

1184 context = self._context 

1185 config = context.config 

1186 

1187 table_name = context.schema.tableName(ExtraTables.DiaObjectLastToPartition) 

1188 queries = [] 

1189 object_count = 0 

1190 for id_chunk in chunk_iterable(ids, 10_000): 

1191 id_chunk_list = list(id_chunk) 

1192 query = ( 

1193 f'SELECT "diaObjectId", apdb_part FROM "{self._keyspace}"."{table_name}" ' 

1194 f'WHERE "diaObjectId" in ({",".join(str(oid) for oid in id_chunk_list)})' 

1195 ) 

1196 queries.append((query, ())) 

1197 object_count += len(id_chunk_list) 

1198 

1199 with self._timer("query_object_last_partitions", tags={"table": table_name}) as timer: 

1200 data = cast( 

1201 ApdbTableData, 

1202 select_concurrent( 

1203 context.session, 

1204 queries, 

1205 "read_raw_multi", 

1206 config.connection_config.read_concurrency, 

1207 ), 

1208 ) 

1209 timer.add_values(object_count=object_count, row_count=len(data.rows())) 

1210 

1211 if data.column_names() != ["diaObjectId", "apdb_part"]: 

1212 raise RuntimeError(f"Unexpected column names in query result: {data.column_names()}") 

1213 

1214 return {row[0]: row[1] for row in data.rows()} 

1215 

1216 def _deleteMovingObjects(self, objs: pandas.DataFrame) -> None: 

1217 """Objects in DiaObjectsLast can move from one spatial partition to 

1218 another. For those objects inserting new version does not replace old 

1219 one, so we need to explicitly remove old versions before inserting new 

1220 ones. 

1221 """ 

1222 context = self._context 

1223 

1224 # Extract all object IDs. 

1225 new_partitions = dict(zip(objs["diaObjectId"], objs["apdb_part"])) 

1226 old_partitions = self._queryDiaObjectLastPartitions(objs["diaObjectId"]) 

1227 

1228 moved_oids: dict[int, tuple[int, int]] = {} 

1229 for oid, old_part in old_partitions.items(): 

1230 new_part = new_partitions.get(oid, old_part) 

1231 if new_part != old_part: 

1232 moved_oids[oid] = (old_part, new_part) 

1233 _LOG.debug("DiaObject IDs that moved to new partition: %s", moved_oids) 

1234 

1235 if moved_oids: 

1236 # Delete old records from DiaObjectLast. 

1237 table_name = context.schema.tableName(ApdbTables.DiaObjectLast) 

1238 query = f'DELETE FROM "{self._keyspace}"."{table_name}" WHERE apdb_part = ? AND "diaObjectId" = ?' 

1239 statement = context.preparer.prepare(query) 

1240 queries = [] 

1241 for oid, (old_part, _) in moved_oids.items(): 

1242 queries.append((statement, (old_part, oid))) 

1243 with self._timer("delete_object_last", tags={"table": table_name}) as timer: 

1244 execute_concurrent(context.session, queries, execution_profile="write") 

1245 timer.add_values(row_count=len(moved_oids)) 

1246 

1247 # Add all new records to the map. 

1248 table_name = context.schema.tableName(ExtraTables.DiaObjectLastToPartition) 

1249 query = f'INSERT INTO "{self._keyspace}"."{table_name}" ("diaObjectId", apdb_part) VALUES (?,?)' 

1250 statement = context.preparer.prepare(query) 

1251 

1252 queries = [] 

1253 for oid, new_part in new_partitions.items(): 

1254 queries.append((statement, (oid, new_part))) 

1255 

1256 with self._timer("update_object_last_partition", tags={"table": table_name}) as timer: 

1257 execute_concurrent(context.session, queries, execution_profile="write") 

1258 timer.add_values(row_count=len(queries)) 

1259 

1260 def _storeDiaObjects( 

1261 self, objs: pandas.DataFrame, visit_time: astropy.time.Time, replica_chunk: ReplicaChunk | None 

1262 ) -> None: 

1263 """Store catalog of DiaObjects from current visit. 

1264 

1265 Parameters 

1266 ---------- 

1267 objs : `pandas.DataFrame` 

1268 Catalog with DiaObject records 

1269 visit_time : `astropy.time.Time` 

1270 Time of the current visit. 

1271 replica_chunk : `ReplicaChunk` or `None` 

1272 Replica chunk identifier if replication is configured. 

1273 """ 

1274 if len(objs) == 0: 

1275 _LOG.debug("No objects to write to database.") 

1276 return 

1277 

1278 context = self._context 

1279 config = context.config 

1280 

1281 self._deleteMovingObjects(objs) 

1282 

1283 validity_start_column = self._timestamp_column_name("validityStart") 

1284 timestamp = self._timestamp_column_value(visit_time) 

1285 

1286 # DiaObjectLast did not have this column in the past. 

1287 extra_columns: dict[str, Any] = {} 

1288 if context.schema.check_column(ApdbTables.DiaObjectLast, validity_start_column): 

1289 extra_columns[validity_start_column] = timestamp 

1290 

1291 self._storeObjectsPandas(objs, ApdbTables.DiaObjectLast, extra_columns=extra_columns) 

1292 

1293 extra_columns[validity_start_column] = timestamp 

1294 visit_time_part = context.partitioner.time_partition(visit_time) 

1295 time_part: int | None = visit_time_part 

1296 if (time_partitions_range := context.time_partitions_range) is not None: 

1297 self._check_time_partitions([visit_time_part], time_partitions_range) 

1298 if not config.partitioning.time_partition_tables: 

1299 extra_columns["apdb_time_part"] = time_part 

1300 time_part = None 

1301 

1302 # Only store DiaObects if not doing replication or explicitly 

1303 # configured to always store them. 

1304 if replica_chunk is None or not config.replica_skips_diaobjects: 

1305 self._storeObjectsPandas( 

1306 objs, ApdbTables.DiaObject, extra_columns=extra_columns, time_part=time_part 

1307 ) 

1308 

1309 if replica_chunk is not None: 

1310 extra_columns = {"apdb_replica_chunk": replica_chunk.id, validity_start_column: timestamp} 

1311 table = ExtraTables.DiaObjectChunks 

1312 if context.has_chunk_sub_partitions: 

1313 table = ExtraTables.DiaObjectChunks2 

1314 # Use a random number for a second part of partitioning key so 

1315 # that different clients could wrtite to different partitions. 

1316 # This makes it not exactly reproducible. 

1317 extra_columns["apdb_replica_subchunk"] = random.randrange(config.replica_sub_chunk_count) 

1318 self._storeObjectsPandas(objs, table, extra_columns=extra_columns) 

1319 

1320 # Store copy of the records in dedup table. 

1321 if context.has_dedup_table: 

1322 table = ExtraTables.DiaObjectDedup 

1323 extra_columns = { 

1324 "dedup_part": random.randrange(config.partitioning.num_part_dedup), 

1325 validity_start_column: timestamp, 

1326 } 

1327 self._storeObjectsPandas(objs, table, extra_columns=extra_columns) 

1328 

1329 def _storeDiaSources( 

1330 self, 

1331 table_name: ApdbTables, 

1332 sources: pandas.DataFrame, 

1333 replica_chunk: ReplicaChunk | None, 

1334 ) -> int | None: 

1335 """Store catalog of DIASources or DIAForcedSources from current visit. 

1336 

1337 Parameters 

1338 ---------- 

1339 table_name : `ApdbTables` 

1340 Table where to store the data. 

1341 sources : `pandas.DataFrame` 

1342 Catalog containing DiaSource records 

1343 visit_time : `astropy.time.Time` 

1344 Time of the current visit. 

1345 replica_chunk : `ReplicaChunk` or `None` 

1346 Replica chunk identifier if replication is configured. 

1347 

1348 Returns 

1349 ------- 

1350 subchunk : `int` or `None` 

1351 Subchunk number for resulting replica data, `None` if relication is 

1352 not enabled ot subchunking is not enabled. 

1353 """ 

1354 context = self._context 

1355 config = context.config 

1356 

1357 # Time partitioning has to be based on midpointMjdTai, not visit_time 

1358 # as visit_time is not really a visit time. 

1359 tp_sources = sources.copy(deep=False) 

1360 tp_sources["apdb_time_part"] = tp_sources["midpointMjdTai"].apply(context.partitioner.time_partition) 

1361 if (time_partitions_range := context.time_partitions_range) is not None: 

1362 self._check_time_partitions(tp_sources["apdb_time_part"], time_partitions_range) 

1363 extra_columns: dict[str, Any] = {} 

1364 if not config.partitioning.time_partition_tables: 

1365 self._storeObjectsPandas(tp_sources, table_name) 

1366 else: 

1367 # Group by time partition 

1368 partitions = set(tp_sources["apdb_time_part"]) 

1369 if len(partitions) == 1: 

1370 # Single partition - just save the whole thing. 

1371 time_part = partitions.pop() 

1372 self._storeObjectsPandas(sources, table_name, time_part=time_part) 

1373 else: 

1374 # group by time partition. 

1375 for time_part, sub_frame in tp_sources.groupby(by="apdb_time_part"): 

1376 sub_frame.drop(columns="apdb_time_part", inplace=True) 

1377 self._storeObjectsPandas(sub_frame, table_name, time_part=time_part) 

1378 

1379 subchunk: int | None = None 

1380 if replica_chunk is not None: 

1381 extra_columns = {"apdb_replica_chunk": replica_chunk.id} 

1382 if context.has_chunk_sub_partitions: 

1383 subchunk = random.randrange(config.replica_sub_chunk_count) 

1384 extra_columns["apdb_replica_subchunk"] = subchunk 

1385 if table_name is ApdbTables.DiaSource: 

1386 extra_table = ExtraTables.DiaSourceChunks2 

1387 else: 

1388 extra_table = ExtraTables.DiaForcedSourceChunks2 

1389 else: 

1390 if table_name is ApdbTables.DiaSource: 

1391 extra_table = ExtraTables.DiaSourceChunks 

1392 else: 

1393 extra_table = ExtraTables.DiaForcedSourceChunks 

1394 self._storeObjectsPandas(sources, extra_table, extra_columns=extra_columns) 

1395 

1396 return subchunk 

1397 

1398 def _check_time_partitions( 

1399 self, partitions: Iterable[int], time_partitions_range: ApdbCassandraTimePartitionRange 

1400 ) -> None: 

1401 """Check that time partitons for new data actually exist. 

1402 

1403 Parameters 

1404 ---------- 

1405 partitions : `~collections.abc.Iterable` [`int`] 

1406 Time partitions for new data. 

1407 time_partitions_range : `ApdbCassandraTimePartitionRange` 

1408 Currrent time partition range. 

1409 """ 

1410 partitions = set(partitions) 

1411 min_part = min(partitions) 

1412 max_part = max(partitions) 

1413 if min_part < time_partitions_range.start or max_part > time_partitions_range.end: 

1414 raise ValueError( 

1415 "Attempt to store data for time partitions that do not yet exist. " 

1416 f"Partitons for new records: {min_part}-{max_part}. " 

1417 f"Database partitons: {time_partitions_range.start}-{time_partitions_range.end}." 

1418 ) 

1419 # Make a noise when writing to the last partition. 

1420 if max_part == time_partitions_range.end: 

1421 warnings.warn( 

1422 "Writing into the last temporal partition. Partition range needs to be extended soon.", 

1423 stacklevel=3, 

1424 ) 

1425 

1426 def _storeDiaSourcesPartitions( 

1427 self, 

1428 sources: pandas.DataFrame, 

1429 visit_time: astropy.time.Time, 

1430 replica_chunk: ReplicaChunk | None, 

1431 subchunk: int | None, 

1432 ) -> None: 

1433 """Store mapping of diaSourceId to its partitioning values. 

1434 

1435 Parameters 

1436 ---------- 

1437 sources : `pandas.DataFrame` 

1438 Catalog containing DiaSource records 

1439 visit_time : `astropy.time.Time` 

1440 Time of the current visit. 

1441 replica_chunk : `ReplicaChunk` or `None` 

1442 Replication chunk, or `None` when replication is disabled. 

1443 subchunk : `int` or `None` 

1444 Replication sub-chunk, or `None` when replication is disabled or 

1445 sub-chunking is not used. 

1446 """ 

1447 context = self._context 

1448 

1449 id_map = cast(pandas.DataFrame, sources[["diaSourceId", "apdb_part"]]) 

1450 extra_columns = { 

1451 "apdb_time_part": context.partitioner.time_partition(visit_time), 

1452 "apdb_replica_chunk": replica_chunk.id if replica_chunk is not None else None, 

1453 } 

1454 if context.has_chunk_sub_partitions: 

1455 extra_columns["apdb_replica_subchunk"] = subchunk 

1456 

1457 self._storeObjectsPandas( 

1458 id_map, ExtraTables.DiaSourceToPartition, extra_columns=extra_columns, time_part=None 

1459 ) 

1460 

1461 def _storeObjectsPandas( 

1462 self, 

1463 records: pandas.DataFrame, 

1464 table_name: ApdbTables | ExtraTables, 

1465 extra_columns: Mapping | None = None, 

1466 time_part: int | None = None, 

1467 ) -> None: 

1468 """Store generic objects. 

1469 

1470 Takes Pandas catalog and stores a bunch of records in a table. 

1471 

1472 Parameters 

1473 ---------- 

1474 records : `pandas.DataFrame` 

1475 Catalog containing object records 

1476 table_name : `ApdbTables` 

1477 Name of the table as defined in APDB schema. 

1478 extra_columns : `dict`, optional 

1479 Mapping (column_name, column_value) which gives fixed values for 

1480 columns in each row, overrides values in ``records`` if matching 

1481 columns exist there. 

1482 time_part : `int`, optional 

1483 If not `None` then insert into a per-partition table. 

1484 

1485 Notes 

1486 ----- 

1487 If Pandas catalog contains additional columns not defined in table 

1488 schema they are ignored. Catalog does not have to contain all columns 

1489 defined in a table, but partition and clustering keys must be present 

1490 in a catalog or ``extra_columns``. 

1491 """ 

1492 context = self._context 

1493 

1494 # use extra columns if specified 

1495 if extra_columns is None: 

1496 extra_columns = {} 

1497 extra_fields = list(extra_columns.keys()) 

1498 

1499 # Fields that will come from dataframe. 

1500 df_fields = [column for column in records.columns if column not in extra_fields] 

1501 

1502 column_map = context.schema.getColumnMap(table_name) 

1503 # list of columns (as in felis schema) 

1504 fields = [column_map[field].name for field in df_fields if field in column_map] 

1505 fields += extra_fields 

1506 

1507 # check that all partitioning and clustering columns are defined 

1508 partition_columns = context.schema.partitionColumns(table_name) 

1509 required_columns = partition_columns + context.schema.clusteringColumns(table_name) 

1510 missing_columns = [column for column in required_columns if column not in fields] 

1511 if missing_columns: 

1512 raise ValueError(f"Primary key columns are missing from catalog: {missing_columns}") 

1513 

1514 qfields = [quote_id(field) for field in fields] 

1515 qfields_str = ",".join(qfields) 

1516 

1517 batch_size = self._batch_size(table_name) 

1518 

1519 with self._timer("insert_build_time", tags={"table": table_name.name}): 

1520 # Multi-partition batches are problematic in general, so we want to 

1521 # group records in a batch by their partition key. 

1522 values_by_key: dict[tuple, list[list]] = defaultdict(list) 

1523 for rec in records.itertuples(index=False): 

1524 values = [] 

1525 partitioning_values: dict[str, Any] = {} 

1526 for field in df_fields: 

1527 if field not in column_map: 

1528 continue 

1529 value = getattr(rec, field) 

1530 if column_map[field].datatype is felis.datamodel.DataType.timestamp: 

1531 if isinstance(value, pandas.Timestamp): 

1532 value = value.to_pydatetime() 

1533 elif value is pandas.NaT: 

1534 value = None 

1535 else: 

1536 # Assume it's seconds since epoch, Cassandra 

1537 # datetime is in milliseconds 

1538 value = int(value * 1000) 

1539 value = literal(value) 

1540 values.append(UNSET_VALUE if value is None else value) 

1541 if field in partition_columns: 

1542 partitioning_values[field] = value 

1543 for field in extra_fields: 

1544 value = literal(extra_columns[field]) 

1545 values.append(UNSET_VALUE if value is None else value) 

1546 if field in partition_columns: 

1547 partitioning_values[field] = value 

1548 

1549 key = tuple(partitioning_values[field] for field in partition_columns) 

1550 values_by_key[key].append(values) 

1551 

1552 table = context.schema.tableName(table_name, time_part) 

1553 

1554 holders = ",".join(["?"] * len(qfields)) 

1555 query = f'INSERT INTO "{self._keyspace}"."{table}" ({qfields_str}) VALUES ({holders})' 

1556 statement = context.preparer.prepare(query) 

1557 # Cassandra has 64k limit on batch size, normally that should be 

1558 # enough but some tests generate too many forced sources. 

1559 queries = [] 

1560 for key_values in values_by_key.values(): 

1561 for values_chunk in chunk_iterable(key_values, batch_size): 

1562 batch = cassandra.query.BatchStatement() 

1563 for row_values in values_chunk: 

1564 batch.add(statement, row_values) 

1565 queries.append((batch, None)) 

1566 assert batch.routing_key is not None and batch.keyspace is not None 

1567 

1568 _LOG.debug("%s: will store %d records", context.schema.tableName(table_name), records.shape[0]) 

1569 with self._timer( 

1570 "insert_time", tags={"table": table_name.name, "method": "_storeObjectsPandas"} 

1571 ) as timer: 

1572 execute_concurrent(context.session, queries, execution_profile="write") 

1573 timer.add_values(row_count=len(records), num_batches=len(queries)) 

1574 

1575 def _storeUpdateRecords( 

1576 self, records: Iterable[ApdbUpdateRecord], chunk: ReplicaChunk, *, store_chunk: bool = False 

1577 ) -> None: 

1578 """Store ApdbUpdateRecords in the replica table for those records. 

1579 

1580 Parameters 

1581 ---------- 

1582 records : `list` [`ApdbUpdateRecord`] 

1583 Records to store. 

1584 chunk : `ReplicaChunk` 

1585 Replica chunk for these records. 

1586 store_chunk : `bool` 

1587 If True then also store replica chunk. 

1588 

1589 Raises 

1590 ------ 

1591 TypeError 

1592 Raised if replication is not enabled for this instance. 

1593 """ 

1594 context = self._context 

1595 config = context.config 

1596 

1597 if not context.schema.replication_enabled: 

1598 raise TypeError("Replication is not enabled for this APDB instance.") 

1599 

1600 if store_chunk: 

1601 self._storeReplicaChunk(chunk) 

1602 

1603 apdb_replica_chunk = chunk.id 

1604 # Do not use unique_if from ReplicaChunk as it could be reused in 

1605 # multiple calls to this method. 

1606 update_unique_id = uuid.uuid4() 

1607 

1608 rows = [] 

1609 for record in records: 

1610 rows.append( 

1611 [ 

1612 apdb_replica_chunk, 

1613 record.update_time_ns, 

1614 record.update_order, 

1615 update_unique_id, 

1616 record.to_json(), 

1617 ] 

1618 ) 

1619 columns = [ 

1620 "apdb_replica_chunk", 

1621 "update_time_ns", 

1622 "update_order", 

1623 "update_unique_id", 

1624 "update_payload", 

1625 ] 

1626 if context.has_chunk_sub_partitions: 

1627 subchunk = random.randrange(config.replica_sub_chunk_count) 

1628 for row in rows: 

1629 row.append(subchunk) 

1630 columns.append("apdb_replica_subchunk") 

1631 

1632 table_name = context.schema.tableName(ExtraTables.ApdbUpdateRecordChunks) 

1633 placeholders = ", ".join(["%s"] * len(columns)) 

1634 columns_str = ", ".join(columns) 

1635 query = f'INSERT INTO "{self._keyspace}"."{table_name}" ({columns_str}) VALUES ({placeholders})' 

1636 queries = [(query, row) for row in rows] 

1637 

1638 with self._timer("store_update_record", tags={"table": table_name}) as timer: 

1639 execute_concurrent(context.session, queries, execution_profile="write") 

1640 timer.add_values(row_count=len(queries)) 

1641 

1642 def _add_apdb_part(self, df: pandas.DataFrame) -> pandas.DataFrame: 

1643 """Calculate spatial partition for each record and add it to a 

1644 DataFrame. 

1645 

1646 Parameters 

1647 ---------- 

1648 df : `pandas.DataFrame` 

1649 DataFrame which has to contain ra/dec columns, names of these 

1650 columns are defined by configuration ``ra_dec_columns`` field. 

1651 

1652 Returns 

1653 ------- 

1654 df : `pandas.DataFrame` 

1655 DataFrame with ``apdb_part`` column which contains pixel index 

1656 for ra/dec coordinates. 

1657 

1658 Notes 

1659 ----- 

1660 This overrides any existing column in a DataFrame with the same name 

1661 (``apdb_part``). Original DataFrame is not changed, copy of a DataFrame 

1662 is returned. 

1663 """ 

1664 context = self._context 

1665 config = context.config 

1666 

1667 # Calculate pixelization index for every record. 

1668 apdb_part = np.zeros(df.shape[0], dtype=np.int64) 

1669 ra_col, dec_col = config.ra_dec_columns 

1670 for i, (ra, dec) in enumerate(zip(df[ra_col], df[dec_col])): 

1671 uv3d = sphgeom.UnitVector3d(sphgeom.LonLat.fromDegrees(ra, dec)) 

1672 idx = context.partitioner.pixel(uv3d) 

1673 apdb_part[i] = idx 

1674 df = df.copy() 

1675 df["apdb_part"] = apdb_part 

1676 return df 

1677 

1678 def _make_empty_catalog(self, table_name: ApdbTables) -> pandas.DataFrame: 

1679 """Make an empty catalog for a table with a given name. 

1680 

1681 Parameters 

1682 ---------- 

1683 table_name : `ApdbTables` 

1684 Name of the table. 

1685 

1686 Returns 

1687 ------- 

1688 catalog : `pandas.DataFrame` 

1689 An empty catalog. 

1690 """ 

1691 table = self.schema.tableSchemas[table_name] 

1692 

1693 data = {columnDef.name: pandas.Series(dtype=columnDef.pandas_type) for columnDef in table.columns} 

1694 return pandas.DataFrame(data) 

1695 

1696 def _combine_where( 

1697 self, 

1698 prefix: str, 

1699 where1: list[tuple[str, tuple]], 

1700 where2: list[tuple[str, tuple]], 

1701 where3: tuple[str, tuple] | None = None, 

1702 suffix: str | None = None, 

1703 ) -> Iterator[tuple[cassandra.query.Statement, tuple]]: 

1704 """Make cartesian product of two parts of WHERE clause into a series 

1705 of statements to execute. 

1706 

1707 Parameters 

1708 ---------- 

1709 prefix : `str` 

1710 Initial statement prefix that comes before WHERE clause, e.g. 

1711 "SELECT * from Table" 

1712 """ 

1713 context = self._context 

1714 

1715 # If lists are empty use special sentinels. 

1716 if not where1: 

1717 where1 = [("", ())] 

1718 if not where2: 

1719 where2 = [("", ())] 

1720 

1721 for expr1, params1 in where1: 

1722 for expr2, params2 in where2: 

1723 full_query = prefix 

1724 wheres = [] 

1725 params = params1 + params2 

1726 if expr1: 

1727 wheres.append(expr1) 

1728 if expr2: 

1729 wheres.append(expr2) 

1730 if where3: 

1731 wheres.append(where3[0]) 

1732 params += where3[1] 

1733 if wheres: 

1734 full_query += " WHERE " + " AND ".join(wheres) 

1735 if suffix: 

1736 full_query += " " + suffix 

1737 if params: 

1738 statement = context.preparer.prepare(full_query) 

1739 else: 

1740 # If there are no params then it is likely that query 

1741 # has a bunch of literals rendered already, no point 

1742 # trying to prepare it. 

1743 statement = cassandra.query.SimpleStatement(full_query) 

1744 yield (statement, params) 

1745 

1746 def _fix_input_timestamps(self, df: pandas.DataFrame) -> pandas.DataFrame: 

1747 """Update timestamp columns in input DataFrame to be naive datetime 

1748 type. 

1749 

1750 Clients may or may not generate aware timestamps, code in this class 

1751 assumes that timestamps are naive, so we convert them to UTC and 

1752 drop timezone. 

1753 """ 

1754 # Find all columns with aware timestamps. 

1755 columns = [column for column, dtype in df.dtypes.items() if isinstance(dtype, pandas.DatetimeTZDtype)] 

1756 for column in columns: 

1757 # tz_convert(None) will convert to UTC and drop timezone. 

1758 df[column] = df[column].dt.tz_convert(None) 

1759 return df 

1760 

1761 def _batch_size(self, table: ApdbTables | ExtraTables) -> int: 

1762 """Calculate batch size based on config parameters.""" 

1763 context = self._context 

1764 config = context.config 

1765 

1766 # Cassandra limit on number of statements in a batch is 64k. 

1767 batch_size = 65_535 

1768 if 0 < config.batch_statement_limit < batch_size: 

1769 batch_size = config.batch_statement_limit 

1770 if config.batch_size_limit > 0: 

1771 # The purpose of this limit is to try not to exceed batch size 

1772 # threshold which is set on server side. Cassandra wire protocol 

1773 # for prepared queries (and batches) only sends column values with 

1774 # with an additional 4 bytes per value specifying size. Value is 

1775 # not included for NULL or NOT_SET values, but the size is always 

1776 # there. There is additional small per-query overhead, which we 

1777 # ignore. 

1778 row_size = context.schema.table_row_size(table) 

1779 row_size += 4 * len(context.schema.getColumnMap(table)) 

1780 batch_size = min(batch_size, (config.batch_size_limit // row_size) + 1) 

1781 return batch_size 

1782 

1783 def _group_dia_objects_by_partition( 

1784 self, partitioner: Partitioner, objects: list[DiaObjectId], pad_arcsec: float 

1785 ) -> Mapping[int, list[int]]: 

1786 """Group DiaObjects by partition. 

1787 

1788 Parameters 

1789 ---------- 

1790 partitioner : `Partitioner` 

1791 Objects which knows how to partition things. 

1792 objects : `list` [`DiaObjectId`] 

1793 Collection of objects to partition. 

1794 pad_arcsec : `float` 

1795 Additional padding around object position. 

1796 

1797 Returns 

1798 ------- 

1799 grouped_objects 

1800 Mapping of spatial patition ID to list ob object IDs that it 

1801 contains. Some objects may belong to more than one partition. 

1802 """ 

1803 partitioned_object_ids: dict[int, list[int]] = defaultdict(list) 

1804 for obj_id in objects: 

1805 partitions = partitioner.pixelization.circle_pixels(obj_id.ra, obj_id.dec, pad_arcsec) 

1806 for pixel in partitions: 

1807 partitioned_object_ids[pixel].append(obj_id.diaObjectId) 

1808 return partitioned_object_ids 

1809 

1810 def _timestamp_column_name(self, column: str) -> str: 

1811 """Return column name before/after schema migration to MJD TAI.""" 

1812 return self._schema.timestamp_column_name(column) 

1813 

1814 def _timestamp_column_value(self, time: astropy.time.Time) -> float | int: 

1815 """Return column value before/after schema migration to MJD TAI.""" 

1816 if self._schema.has_mjd_timestamps: 

1817 return float(time.tai.mjd) 

1818 else: 

1819 return int(time.datetime.astimezone(tz=datetime.UTC).timestamp() * 1000) 

1820 

1821 def _get_diasource_data(self, source_ids: Iterable[DiaSourceId], *columns: str) -> list: 

1822 """Select records from DiaSource table by diaSourceId and return all 

1823 records as a list of named tuples. 

1824 """ 

1825 context = self._context 

1826 config = context.config 

1827 partitioner = context.partitioner 

1828 

1829 columns = ("diaSourceId",) + columns 

1830 what = ",".join(quote_id(column) for column in columns) 

1831 

1832 # Allow some uncertainty for coordinates and time when calculating 

1833 # partitions. 

1834 statements: list[tuple] = [] 

1835 pad_arcsec = 1.0 

1836 pad_time_day = 10 / (24 * 3600) 

1837 for source_id in source_ids: 

1838 center = sphgeom.UnitVector3d(sphgeom.LonLat.fromDegrees(source_id.ra, source_id.dec)) 

1839 region = sphgeom.Circle(center, sphgeom.Angle.fromDegrees(pad_arcsec / 3600.0)) 

1840 spatial_where, _ = partitioner.spatial_where(region, for_prepare=True) 

1841 

1842 tables, temporal_where = partitioner.temporal_where( 

1843 ApdbTables.DiaSource, 

1844 source_id.midpointMjdTai - pad_time_day, 

1845 source_id.midpointMjdTai + pad_time_day, 

1846 for_prepare=True, 

1847 partitons_range=context.time_partitions_range, 

1848 query_per_time_part=True, 

1849 ) 

1850 

1851 id_where = ('"diaSourceId" = ?', (source_id.diaSourceId,)) 

1852 

1853 for table in tables: 

1854 prefix = f'SELECT {what} FROM "{self._keyspace}"."{table}"' 

1855 statements += list(self._combine_where(prefix, spatial_where, temporal_where, id_where)) 

1856 

1857 with self._timer( 

1858 "select_time", tags={"table": "DiaSource", "method": "_get_diasource_data"} 

1859 ) as timer: 

1860 result = cast( 

1861 list[tuple], 

1862 select_concurrent( 

1863 context.session, 

1864 statements, 

1865 "read_named_tuples", 

1866 config.connection_config.read_concurrency, 

1867 ), 

1868 ) 

1869 timer.add_values(row_count=len(result), num_queries=len(statements)) 

1870 

1871 return result 

1872 

1873 def _get_diaobject_data(self, object_ids: Iterable[DiaObjectId], *columns: str) -> list: 

1874 """Select records from DiaObjectLast table by diaObjectId and return 

1875 all records as a list of named tuples. 

1876 """ 

1877 context = self._context 

1878 config = context.config 

1879 partitioner = context.partitioner 

1880 

1881 table_name = context.schema.tableName(ApdbTables.DiaObjectLast) 

1882 columns = ("diaObjectId",) + columns 

1883 what = ",".join(quote_id(column) for column in columns) 

1884 

1885 # Allow some uncertainty for coordinates when calculating partitions. 

1886 pad_arcsec = 1.0 

1887 ids_by_partition = defaultdict(list) 

1888 for object_id in object_ids: 

1889 pixels = partitioner.pixelization.circle_pixels(object_id.ra, object_id.dec, pad_arcsec) 

1890 for pixel in pixels: 

1891 ids_by_partition[pixel].append(object_id.diaObjectId) 

1892 

1893 statements: list[tuple] = [] 

1894 for apdb_part, diaObjectIds in ids_by_partition.items(): 

1895 ids_str = ",".join(str(diaObjectId) for diaObjectId in diaObjectIds) 

1896 statement = cassandra.query.SimpleStatement( 

1897 f'SELECT {what} FROM "{self._keyspace}"."{table_name}" ' 

1898 f'WHERE apdb_part = %s AND "diaObjectId" IN ({ids_str})' 

1899 ) 

1900 statements.append((statement, (apdb_part,))) 

1901 

1902 with self._timer( 

1903 "select_time", tags={"table": "DiaObjectLast", "method": "_get_diaobject_data"} 

1904 ) as timer: 

1905 result = cast( 

1906 list[tuple], 

1907 select_concurrent( 

1908 context.session, 

1909 statements, 

1910 "read_named_tuples", 

1911 config.connection_config.read_concurrency, 

1912 ), 

1913 ) 

1914 timer.add_values(row_count=len(result), num_queries=len(statements)) 

1915 

1916 return result