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

859 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-04 01:08 -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 config = context.config 

627 

628 # If ApdbDetectorVisit table exists just check it. 

629 if context.has_visit_detector_table: 

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

631 query = ( 

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

633 ) 

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

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

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

637 

638 # The order of checks corresponds to order in store(), on potential 

639 # store failure earlier tables have higher probability containing 

640 # stored records. With per-partition tables there will be many tables 

641 # in the list, but it is unlikely that we'll use that setup in 

642 # production. 

643 sp_where, _ = context.partitioner.spatial_where(region, use_ranges=True, for_prepare=True) 

644 visit_detector_where = ("visit = ? AND detector = ?", (visit, detector)) 

645 

646 # Sources are partitioned on their midPointMjdTai. To avoid precision 

647 # issues add some fuzziness to visit time. 

648 mjd_start = float(visit_time.tai.mjd) - 1.0 / 24 

649 mjd_end = float(visit_time.tai.mjd) + 1.0 / 24 

650 

651 statements: list[tuple] = [] 

652 for table_type in ApdbTables.DiaSource, ApdbTables.DiaForcedSource: 

653 tables, temporal_where = context.partitioner.temporal_where( 

654 table_type, mjd_start, mjd_end, query_per_time_part=True, for_prepare=True 

655 ) 

656 for table in tables: 

657 prefix = f'SELECT apdb_part FROM "{self._keyspace}"."{table}"' 

658 # Needs ALLOW FILTERING as there is no PK constraint. 

659 suffix = "PER PARTITION LIMIT 1 LIMIT 1 ALLOW FILTERING" 

660 statements += list( 

661 self._combine_where(prefix, sp_where, temporal_where, visit_detector_where, suffix) 

662 ) 

663 

664 with self._timer("contains_visit_detector_time", tags={"table": "DiaSource"}): 

665 result = cast( 

666 list[tuple[int] | None], 

667 select_concurrent( 

668 context.session, 

669 statements, 

670 "read_tuples", 

671 config.connection_config.read_concurrency, 

672 ), 

673 ) 

674 return bool(result) 

675 

676 def store( 

677 self, 

678 visit_time: astropy.time.Time, 

679 objects: pandas.DataFrame, 

680 sources: pandas.DataFrame | None = None, 

681 forced_sources: pandas.DataFrame | None = None, 

682 ) -> None: 

683 # docstring is inherited from a base class 

684 context = self._context 

685 config = context.config 

686 

687 if context.has_visit_detector_table: 

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

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

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

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

692 for df in sources, forced_sources: 

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

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

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

696 visit_detector.add((visit, detector)) 

697 

698 if visit_detector: 

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

700 # concurrency. 

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

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

703 for item in visit_detector: 

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

705 

706 objects = self._fix_input_timestamps(objects) 

707 if sources is not None: 

708 sources = self._fix_input_timestamps(sources) 

709 if forced_sources is not None: 

710 forced_sources = self._fix_input_timestamps(forced_sources) 

711 

712 replica_chunk: ReplicaChunk | None = None 

713 if context.schema.replication_enabled: 

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

715 self._storeReplicaChunk(replica_chunk) 

716 

717 # fill region partition column for DiaObjects 

718 objects = self._add_apdb_part(objects) 

719 self._storeDiaObjects(objects, visit_time, replica_chunk) 

720 

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

722 # copy apdb_part column from DiaObjects to DiaSources 

723 sources = self._add_apdb_part(sources) 

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

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

726 

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

728 forced_sources = self._add_apdb_part(forced_sources) 

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

730 

731 def reassignDiaSourcesToDiaObjects( 

732 self, 

733 idMap: Mapping[DiaSourceId, int], 

734 *, 

735 increment_nDiaSources: bool = True, 

736 decrement_nDiaSources: bool = True, 

737 ) -> None: 

738 # docstring is inherited from a base class 

739 context = self._context 

740 config = context.config 

741 

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

743 

744 # Find all DiaSources. 

745 found_sources = self._get_diasource_data( 

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

747 ) 

748 

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

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

751 

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

753 

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

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

756 # send updated values to replica. 

757 current_object_ids = { 

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

759 } 

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

761 new_object_ids = { 

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

763 for source_id, diaObjectId in idMap.items() 

764 } 

765 all_object_ids = new_object_ids | current_object_ids 

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

767 

768 if missing_ids := ( 

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

770 ): 

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

772 

773 update_records: list[ApdbUpdateRecord] = [] 

774 update_order = 0 

775 current_time = self._current_time() 

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

777 

778 # Update DiaSources. 

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

780 statements: list[tuple] = [] 

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

782 source_row = found_sources_by_id[source_id.diaSourceId] 

783 apdb_part = source_row.apdb_part 

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

785 

786 if config.partitioning.time_partition_tables: 

787 statement = context.preparer.prepare( 

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

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

790 ) 

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

792 else: 

793 statement = context.preparer.prepare( 

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

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

796 ) 

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

798 

799 if context.schema.replication_enabled: 

800 update_records.append( 

801 ApdbReassignDiaSourceToDiaObjectRecord( 

802 diaSourceId=source_id.diaSourceId, 

803 ra=source_id.ra, 

804 dec=source_id.dec, 

805 midpointMjdTai=source_id.midpointMjdTai, 

806 diaObjectId=diaObjectId, 

807 update_time_ns=current_time_ns, 

808 update_order=update_order, 

809 ) 

810 ) 

811 update_order += 1 

812 

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 # Update nDiaSources in DiaObjectLast. We do not update DiaObject table 

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

821 # update records. 

822 if increment_nDiaSources or decrement_nDiaSources: 

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

824 statement = context.preparer.prepare( 

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

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

827 ) 

828 statements = [] 

829 

830 # Calculate increments/decrements for all affected DiaObjects. 

831 increments: Counter = Counter() 

832 if increment_nDiaSources: 

833 increments.update(idMap.values()) 

834 if decrement_nDiaSources: 

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

836 

837 for row in found_objects: 

838 if increments.get(row.diaObjectId): 

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

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

841 

842 # Also send updated values to replica. 

843 if context.schema.replication_enabled: 

844 update_records.append( 

845 ApdbUpdateNDiaSourcesRecord( 

846 diaObjectId=row.diaObjectId, 

847 ra=row.ra, 

848 dec=row.dec, 

849 nDiaSources=nDiaSources, 

850 update_time_ns=current_time_ns, 

851 update_order=update_order, 

852 ) 

853 ) 

854 update_order += 1 

855 

856 if statements: 

857 with self._timer( 

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

859 ) as timer: 

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

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

862 

863 if update_records: 

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

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

866 

867 def setValidityEnd( 

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

869 ) -> int: 

870 # docstring is inherited from a base class 

871 if not objects: 

872 return 0 

873 

874 context = self._context 

875 config = context.config 

876 

877 pad_arcsec = 1.0 

878 partitioned_object_ids = self._group_dia_objects_by_partition( 

879 context.partitioner, objects, pad_arcsec 

880 ) 

881 

882 # Check that all objects exist. 

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

884 statements: list[tuple] = [] 

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

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

887 query = ( 

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

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

890 ) 

891 statement = cassandra.query.SimpleStatement(query) 

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

893 

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

895 records = cast( 

896 list[tuple[int, int]], 

897 select_concurrent( 

898 context.session, 

899 statements, 

900 "read_tuples", 

901 config.connection_config.read_concurrency, 

902 ), 

903 ) 

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

905 

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

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

908 if extra_ids := (found_ids - requested_ids): 

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

910 if raise_on_missing_id: 

911 if missing_ids := (requested_ids - found_ids): 

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

913 

914 # Filter existing records. 

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

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

917 

918 if not objects: 

919 return 0 

920 

921 # Group by partitions again. 

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

923 for apdb_part, diaObjectId in records: 

924 grouped_object_ids[apdb_part].append(diaObjectId) 

925 

926 # Remove all matching rows from DiaObjectLast. 

927 statements = [] 

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

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

930 query = ( 

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

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

933 ) 

934 statement = cassandra.query.SimpleStatement(query) 

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

936 

937 # Also remove from DiaObjectLastToPartition. 

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

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

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

941 statement = cassandra.query.SimpleStatement(query) 

942 statements.append((statement, ())) 

943 

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

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

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

947 

948 # If repication is enabled then send all updates. 

949 if context.schema.replication_enabled: 

950 current_time = self._current_time() 

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

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

953 

954 update_records = [ 

955 ApdbCloseDiaObjectValidityRecord( 

956 diaObjectId=obj.diaObjectId, 

957 ra=obj.ra, 

958 dec=obj.dec, 

959 update_time_ns=current_time_ns, 

960 update_order=index, 

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

962 nDiaSources=None, 

963 ) 

964 for index, obj in enumerate(objects) 

965 ] 

966 

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

968 

969 return len(objects) 

970 

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

972 # docstring is inherited from a base class 

973 context = self._context 

974 

975 if not context.has_dedup_table: 

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

977 

978 if dedup_time is None: 

979 dedup_time = self._current_time() 

980 

981 validity_start_column = self._timestamp_column_name("validityStart") 

982 

983 # Find latest timestamp in deduplication table. 

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

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

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

987 max_value = result.one()[0] 

988 if self._schema.has_mjd_timestamps: 

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

990 else: 

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

992 

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

994 if dedup_time >= max_validity_start: 

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

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

997 else: 

998 dedup_time_value = self._timestamp_column_value(dedup_time) 

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

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

1001 

1002 # Store dedup time. 

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

1004 data_json = json.dumps(data) 

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

1006 

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

1008 # docstring is inherited from a base class 

1009 context = self._context 

1010 config = context.config 

1011 

1012 now = self._current_time() 

1013 reassign_time_column = self._timestamp_column_name("ssObjectReassocTime") 

1014 reassignTime = self._timestamp_column_value(now) 

1015 

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

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

1018 # primary keys. 

1019 

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

1021 # split it into 1k IDs per query 

1022 selects: list[tuple] = [] 

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

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

1025 selects.append( 

1026 ( 

1027 ( 

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

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

1030 ), 

1031 {}, 

1032 ) 

1033 ) 

1034 

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

1036 result = cast( 

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

1038 select_concurrent( 

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

1040 ), 

1041 ) 

1042 

1043 # Make mapping from source ID to its partition. 

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

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

1046 for row in result: 

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

1048 if row[3] is not None: 

1049 id2chunk_id[row[0]] = row[3] 

1050 

1051 # make sure we know partitions for each ID 

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

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

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

1055 

1056 # Reassign in standard tables 

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

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

1059 apdb_part, apdb_time_part = id2partitions[diaSourceId] 

1060 values: tuple 

1061 if config.partitioning.time_partition_tables: 

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

1063 query = ( 

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

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

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

1067 ) 

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

1069 else: 

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

1071 query = ( 

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

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

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

1075 ) 

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

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

1078 

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

1080 if id2chunk_id: 

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

1082 

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

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

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

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

1087 

1088 def countUnassociatedObjects(self) -> int: 

1089 # docstring is inherited from a base class 

1090 

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

1092 raise NotImplementedError() 

1093 

1094 @property 

1095 def schema(self) -> ApdbSchema: 

1096 # docstring is inherited from a base class 

1097 return self._schema 

1098 

1099 @property 

1100 def metadata(self) -> ApdbMetadata: 

1101 # docstring is inherited from a base class 

1102 context = self._context 

1103 return context.metadata 

1104 

1105 @property 

1106 def admin(self) -> ApdbCassandraAdmin: 

1107 # docstring is inherited from a base class 

1108 return ApdbCassandraAdmin(self) 

1109 

1110 def _getSources( 

1111 self, 

1112 region: sphgeom.Region, 

1113 object_ids: Iterable[int] | None, 

1114 mjd_start: float, 

1115 mjd_end: float, 

1116 table_name: ApdbTables, 

1117 ) -> pandas.DataFrame: 

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

1119 

1120 Parameters 

1121 ---------- 

1122 region : `lsst.sphgeom.Region` 

1123 Spherical region. 

1124 object_ids : 

1125 Collection of DiaObject IDs 

1126 mjd_start : `float` 

1127 Lower bound of time interval. 

1128 mjd_end : `float` 

1129 Upper bound of time interval. 

1130 table_name : `ApdbTables` 

1131 Name of the table. 

1132 

1133 Returns 

1134 ------- 

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

1136 Catalog containing DiaSource records. Empty catalog is returned if 

1137 ``object_ids`` is empty. 

1138 """ 

1139 context = self._context 

1140 config = context.config 

1141 

1142 object_id_set: Set[int] = set() 

1143 if object_ids is not None: 

1144 object_id_set = set(object_ids) 

1145 if len(object_id_set) == 0: 

1146 return self._make_empty_catalog(table_name) 

1147 

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

1149 tables, temporal_where = context.partitioner.temporal_where( 

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

1151 ) 

1152 if not tables: 

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

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

1155 warnings.warn( 

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

1157 ) 

1158 

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

1160 column_names = context.schema.apdbColumnNames(table_name) 

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

1162 

1163 # Build all queries 

1164 statements: list[tuple] = [] 

1165 for table in tables: 

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

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

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

1169 

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

1171 table_data_raw = cast( 

1172 ApdbCassandraTableData, 

1173 select_concurrent( 

1174 context.session, 

1175 statements, 

1176 "read_raw_multi", 

1177 config.connection_config.read_concurrency, 

1178 ), 

1179 ) 

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

1181 timer.add_values( 

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

1183 ) 

1184 

1185 # filter by given object IDs 

1186 if len(object_id_set) > 0: 

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

1188 

1189 # precise filtering on midpointMjdTai 

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

1191 

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

1193 

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

1195 return catalog 

1196 

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

1198 context = self._context 

1199 config = context.config 

1200 

1201 # Cassandra timestamp uses milliseconds since epoch 

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

1203 

1204 # everything goes into a single partition 

1205 partition = 0 

1206 

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

1208 

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

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

1211 if context.has_chunk_sub_partitions: 

1212 columns.append("has_subchunks") 

1213 values.append(True) 

1214 

1215 column_list = ", ".join(columns) 

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

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

1218 

1219 context.session.execute( 

1220 query, 

1221 values, 

1222 timeout=config.connection_config.write_timeout, 

1223 execution_profile="write", 

1224 ) 

1225 

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

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

1228 context = self._context 

1229 config = context.config 

1230 

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

1232 queries = [] 

1233 object_count = 0 

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

1235 id_chunk_list = list(id_chunk) 

1236 query = ( 

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

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

1239 ) 

1240 queries.append((query, ())) 

1241 object_count += len(id_chunk_list) 

1242 

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

1244 data = cast( 

1245 ApdbTableData, 

1246 select_concurrent( 

1247 context.session, 

1248 queries, 

1249 "read_raw_multi", 

1250 config.connection_config.read_concurrency, 

1251 ), 

1252 ) 

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

1254 

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

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

1257 

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

1259 

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

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

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

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

1264 ones. 

1265 """ 

1266 context = self._context 

1267 

1268 # Extract all object IDs. 

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

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

1271 

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

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

1274 new_part = new_partitions.get(oid, old_part) 

1275 if new_part != old_part: 

1276 moved_oids[oid] = (old_part, new_part) 

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

1278 

1279 if moved_oids: 

1280 # Delete old records from DiaObjectLast. 

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

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

1283 statement = context.preparer.prepare(query) 

1284 queries = [] 

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

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

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

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

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

1290 

1291 # Add all new records to the map. 

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

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

1294 statement = context.preparer.prepare(query) 

1295 

1296 queries = [] 

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

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

1299 

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

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

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

1303 

1304 def _storeDiaObjects( 

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

1306 ) -> None: 

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

1308 

1309 Parameters 

1310 ---------- 

1311 objs : `pandas.DataFrame` 

1312 Catalog with DiaObject records 

1313 visit_time : `astropy.time.Time` 

1314 Time of the current visit. 

1315 replica_chunk : `ReplicaChunk` or `None` 

1316 Replica chunk identifier if replication is configured. 

1317 """ 

1318 if len(objs) == 0: 

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

1320 return 

1321 

1322 context = self._context 

1323 config = context.config 

1324 

1325 if context.has_dia_object_last_to_partition: 

1326 self._deleteMovingObjects(objs) 

1327 

1328 validity_start_column = self._timestamp_column_name("validityStart") 

1329 timestamp = self._timestamp_column_value(visit_time) 

1330 

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

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

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

1334 extra_columns[validity_start_column] = timestamp 

1335 

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

1337 

1338 extra_columns[validity_start_column] = timestamp 

1339 visit_time_part = context.partitioner.time_partition(visit_time) 

1340 time_part: int | None = visit_time_part 

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

1342 self._check_time_partitions([visit_time_part], time_partitions_range) 

1343 if not config.partitioning.time_partition_tables: 

1344 extra_columns["apdb_time_part"] = time_part 

1345 time_part = None 

1346 

1347 # Only store DiaObects if not doing replication or explicitly 

1348 # configured to always store them. 

1349 if replica_chunk is None or not config.replica_skips_diaobjects: 

1350 self._storeObjectsPandas( 

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

1352 ) 

1353 

1354 if replica_chunk is not None: 

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

1356 table = ExtraTables.DiaObjectChunks 

1357 if context.has_chunk_sub_partitions: 

1358 table = ExtraTables.DiaObjectChunks2 

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

1360 # that different clients could wrtite to different partitions. 

1361 # This makes it not exactly reproducible. 

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

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

1364 

1365 # Store copy of the records in dedup table. 

1366 if context.has_dedup_table: 

1367 table = ExtraTables.DiaObjectDedup 

1368 extra_columns = { 

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

1370 validity_start_column: timestamp, 

1371 } 

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

1373 

1374 def _storeDiaSources( 

1375 self, 

1376 table_name: ApdbTables, 

1377 sources: pandas.DataFrame, 

1378 replica_chunk: ReplicaChunk | None, 

1379 ) -> int | None: 

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

1381 

1382 Parameters 

1383 ---------- 

1384 table_name : `ApdbTables` 

1385 Table where to store the data. 

1386 sources : `pandas.DataFrame` 

1387 Catalog containing DiaSource records 

1388 visit_time : `astropy.time.Time` 

1389 Time of the current visit. 

1390 replica_chunk : `ReplicaChunk` or `None` 

1391 Replica chunk identifier if replication is configured. 

1392 

1393 Returns 

1394 ------- 

1395 subchunk : `int` or `None` 

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

1397 not enabled ot subchunking is not enabled. 

1398 """ 

1399 context = self._context 

1400 config = context.config 

1401 

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

1403 # as visit_time is not really a visit time. 

1404 tp_sources = sources.copy(deep=False) 

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

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

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

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

1409 if not config.partitioning.time_partition_tables: 

1410 self._storeObjectsPandas(tp_sources, table_name) 

1411 else: 

1412 # Group by time partition 

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

1414 if len(partitions) == 1: 

1415 # Single partition - just save the whole thing. 

1416 time_part = partitions.pop() 

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

1418 else: 

1419 # group by time partition. 

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

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

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

1423 

1424 subchunk: int | None = None 

1425 if replica_chunk is not None: 

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

1427 if context.has_chunk_sub_partitions: 

1428 subchunk = random.randrange(config.replica_sub_chunk_count) 

1429 extra_columns["apdb_replica_subchunk"] = subchunk 

1430 if table_name is ApdbTables.DiaSource: 

1431 extra_table = ExtraTables.DiaSourceChunks2 

1432 else: 

1433 extra_table = ExtraTables.DiaForcedSourceChunks2 

1434 else: 

1435 if table_name is ApdbTables.DiaSource: 

1436 extra_table = ExtraTables.DiaSourceChunks 

1437 else: 

1438 extra_table = ExtraTables.DiaForcedSourceChunks 

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

1440 

1441 return subchunk 

1442 

1443 def _check_time_partitions( 

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

1445 ) -> None: 

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

1447 

1448 Parameters 

1449 ---------- 

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

1451 Time partitions for new data. 

1452 time_partitions_range : `ApdbCassandraTimePartitionRange` 

1453 Currrent time partition range. 

1454 """ 

1455 partitions = set(partitions) 

1456 min_part = min(partitions) 

1457 max_part = max(partitions) 

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

1459 raise ValueError( 

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

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

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

1463 ) 

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

1465 if max_part == time_partitions_range.end: 

1466 warnings.warn( 

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

1468 stacklevel=3, 

1469 ) 

1470 

1471 def _storeDiaSourcesPartitions( 

1472 self, 

1473 sources: pandas.DataFrame, 

1474 visit_time: astropy.time.Time, 

1475 replica_chunk: ReplicaChunk | None, 

1476 subchunk: int | None, 

1477 ) -> None: 

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

1479 

1480 Parameters 

1481 ---------- 

1482 sources : `pandas.DataFrame` 

1483 Catalog containing DiaSource records 

1484 visit_time : `astropy.time.Time` 

1485 Time of the current visit. 

1486 replica_chunk : `ReplicaChunk` or `None` 

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

1488 subchunk : `int` or `None` 

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

1490 sub-chunking is not used. 

1491 """ 

1492 context = self._context 

1493 

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

1495 extra_columns = { 

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

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

1498 } 

1499 if context.has_chunk_sub_partitions: 

1500 extra_columns["apdb_replica_subchunk"] = subchunk 

1501 

1502 self._storeObjectsPandas( 

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

1504 ) 

1505 

1506 def _storeObjectsPandas( 

1507 self, 

1508 records: pandas.DataFrame, 

1509 table_name: ApdbTables | ExtraTables, 

1510 extra_columns: Mapping | None = None, 

1511 time_part: int | None = None, 

1512 ) -> None: 

1513 """Store generic objects. 

1514 

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

1516 

1517 Parameters 

1518 ---------- 

1519 records : `pandas.DataFrame` 

1520 Catalog containing object records 

1521 table_name : `ApdbTables` 

1522 Name of the table as defined in APDB schema. 

1523 extra_columns : `dict`, optional 

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

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

1526 columns exist there. 

1527 time_part : `int`, optional 

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

1529 

1530 Notes 

1531 ----- 

1532 If Pandas catalog contains additional columns not defined in table 

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

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

1535 in a catalog or ``extra_columns``. 

1536 """ 

1537 context = self._context 

1538 

1539 # use extra columns if specified 

1540 if extra_columns is None: 

1541 extra_columns = {} 

1542 extra_fields = list(extra_columns.keys()) 

1543 

1544 # Fields that will come from dataframe. 

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

1546 

1547 column_map = context.schema.getColumnMap(table_name) 

1548 # list of columns (as in felis schema) 

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

1550 fields += extra_fields 

1551 

1552 # check that all partitioning and clustering columns are defined 

1553 partition_columns = context.schema.partitionColumns(table_name) 

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

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

1556 if missing_columns: 

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

1558 

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

1560 qfields_str = ",".join(qfields) 

1561 

1562 batch_size = self._batch_size(table_name) 

1563 

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

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

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

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

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

1569 values = [] 

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

1571 for field in df_fields: 

1572 if field not in column_map: 

1573 continue 

1574 value = getattr(rec, field) 

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

1576 if isinstance(value, pandas.Timestamp): 

1577 value = value.to_pydatetime() 

1578 elif value is pandas.NaT: 

1579 value = None 

1580 else: 

1581 # Assume it's seconds since epoch, Cassandra 

1582 # datetime is in milliseconds 

1583 value = int(value * 1000) 

1584 value = literal(value) 

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

1586 if field in partition_columns: 

1587 partitioning_values[field] = value 

1588 for field in extra_fields: 

1589 value = literal(extra_columns[field]) 

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

1591 if field in partition_columns: 

1592 partitioning_values[field] = value 

1593 

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

1595 values_by_key[key].append(values) 

1596 

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

1598 

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

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

1601 statement = context.preparer.prepare(query) 

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

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

1604 queries = [] 

1605 for key_values in values_by_key.values(): 

1606 for values_chunk in chunk_iterable(key_values, batch_size): 

1607 batch = cassandra.query.BatchStatement() 

1608 for row_values in values_chunk: 

1609 batch.add(statement, row_values) 

1610 queries.append((batch, None)) 

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

1612 

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

1614 with self._timer( 

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

1616 ) as timer: 

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

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

1619 

1620 def _storeUpdateRecords( 

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

1622 ) -> None: 

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

1624 

1625 Parameters 

1626 ---------- 

1627 records : `list` [`ApdbUpdateRecord`] 

1628 Records to store. 

1629 chunk : `ReplicaChunk` 

1630 Replica chunk for these records. 

1631 store_chunk : `bool` 

1632 If True then also store replica chunk. 

1633 

1634 Raises 

1635 ------ 

1636 TypeError 

1637 Raised if replication is not enabled for this instance. 

1638 """ 

1639 context = self._context 

1640 config = context.config 

1641 

1642 if not context.schema.replication_enabled: 

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

1644 

1645 if store_chunk: 

1646 self._storeReplicaChunk(chunk) 

1647 

1648 apdb_replica_chunk = chunk.id 

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

1650 # multiple calls to this method. 

1651 update_unique_id = uuid.uuid4() 

1652 

1653 rows = [] 

1654 for record in records: 

1655 rows.append( 

1656 [ 

1657 apdb_replica_chunk, 

1658 record.update_time_ns, 

1659 record.update_order, 

1660 update_unique_id, 

1661 record.to_json(), 

1662 ] 

1663 ) 

1664 columns = [ 

1665 "apdb_replica_chunk", 

1666 "update_time_ns", 

1667 "update_order", 

1668 "update_unique_id", 

1669 "update_payload", 

1670 ] 

1671 if context.has_chunk_sub_partitions: 

1672 subchunk = random.randrange(config.replica_sub_chunk_count) 

1673 for row in rows: 

1674 row.append(subchunk) 

1675 columns.append("apdb_replica_subchunk") 

1676 

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

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

1679 columns_str = ", ".join(columns) 

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

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

1682 

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

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

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

1686 

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

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

1689 DataFrame. 

1690 

1691 Parameters 

1692 ---------- 

1693 df : `pandas.DataFrame` 

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

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

1696 

1697 Returns 

1698 ------- 

1699 df : `pandas.DataFrame` 

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

1701 for ra/dec coordinates. 

1702 

1703 Notes 

1704 ----- 

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

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

1707 is returned. 

1708 """ 

1709 context = self._context 

1710 config = context.config 

1711 

1712 # Calculate pixelization index for every record. 

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

1714 ra_col, dec_col = config.ra_dec_columns 

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

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

1717 idx = context.partitioner.pixel(uv3d) 

1718 apdb_part[i] = idx 

1719 df = df.copy() 

1720 df["apdb_part"] = apdb_part 

1721 return df 

1722 

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

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

1725 

1726 Parameters 

1727 ---------- 

1728 table_name : `ApdbTables` 

1729 Name of the table. 

1730 

1731 Returns 

1732 ------- 

1733 catalog : `pandas.DataFrame` 

1734 An empty catalog. 

1735 """ 

1736 table = self.schema.tableSchemas[table_name] 

1737 

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

1739 return pandas.DataFrame(data) 

1740 

1741 def _combine_where( 

1742 self, 

1743 prefix: str, 

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

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

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

1747 suffix: str | None = None, 

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

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

1750 of statements to execute. 

1751 

1752 Parameters 

1753 ---------- 

1754 prefix : `str` 

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

1756 "SELECT * from Table" 

1757 """ 

1758 context = self._context 

1759 

1760 # If lists are empty use special sentinels. 

1761 if not where1: 

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

1763 if not where2: 

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

1765 

1766 for expr1, params1 in where1: 

1767 for expr2, params2 in where2: 

1768 full_query = prefix 

1769 wheres = [] 

1770 params = params1 + params2 

1771 if expr1: 

1772 wheres.append(expr1) 

1773 if expr2: 

1774 wheres.append(expr2) 

1775 if where3: 

1776 wheres.append(where3[0]) 

1777 params += where3[1] 

1778 if wheres: 

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

1780 if suffix: 

1781 full_query += " " + suffix 

1782 if params: 

1783 statement = context.preparer.prepare(full_query) 

1784 else: 

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

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

1787 # trying to prepare it. 

1788 statement = cassandra.query.SimpleStatement(full_query) 

1789 yield (statement, params) 

1790 

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

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

1793 type. 

1794 

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

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

1797 drop timezone. 

1798 """ 

1799 # Find all columns with aware timestamps. 

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

1801 for column in columns: 

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

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

1804 return df 

1805 

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

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

1808 context = self._context 

1809 config = context.config 

1810 

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

1812 batch_size = 65_535 

1813 if 0 < config.batch_statement_limit < batch_size: 

1814 batch_size = config.batch_statement_limit 

1815 if config.batch_size_limit > 0: 

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

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

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

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

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

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

1822 # ignore. 

1823 row_size = context.schema.table_row_size(table) 

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

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

1826 return batch_size 

1827 

1828 def _group_dia_objects_by_partition( 

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

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

1831 """Group DiaObjects by partition. 

1832 

1833 Parameters 

1834 ---------- 

1835 partitioner : `Partitioner` 

1836 Objects which knows how to partition things. 

1837 objects : `list` [`DiaObjectId`] 

1838 Collection of objects to partition. 

1839 pad_arcsec : `float` 

1840 Additional padding around object position. 

1841 

1842 Returns 

1843 ------- 

1844 grouped_objects 

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

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

1847 """ 

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

1849 for obj_id in objects: 

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

1851 for pixel in partitions: 

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

1853 return partitioned_object_ids 

1854 

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

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

1857 return self._schema.timestamp_column_name(column) 

1858 

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

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

1861 if self._schema.has_mjd_timestamps: 

1862 return float(time.tai.mjd) 

1863 else: 

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

1865 

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

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

1868 records as a list of named tuples. 

1869 """ 

1870 context = self._context 

1871 config = context.config 

1872 partitioner = context.partitioner 

1873 

1874 columns = ("diaSourceId",) + columns 

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

1876 

1877 # Allow some uncertainty for coordinates and time when calculating 

1878 # partitions. 

1879 statements: list[tuple] = [] 

1880 pad_arcsec = 1.0 

1881 pad_time_day = 10 / (24 * 3600) 

1882 for source_id in source_ids: 

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

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

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

1886 

1887 tables, temporal_where = partitioner.temporal_where( 

1888 ApdbTables.DiaSource, 

1889 source_id.midpointMjdTai - pad_time_day, 

1890 source_id.midpointMjdTai + pad_time_day, 

1891 for_prepare=True, 

1892 partitons_range=context.time_partitions_range, 

1893 query_per_time_part=True, 

1894 ) 

1895 

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

1897 

1898 for table in tables: 

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

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

1901 

1902 with self._timer( 

1903 "select_time", tags={"table": "DiaSource", "method": "_get_diasource_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 

1917 

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

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

1920 all records as a list of named tuples. 

1921 """ 

1922 context = self._context 

1923 config = context.config 

1924 partitioner = context.partitioner 

1925 

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

1927 columns = ("diaObjectId",) + columns 

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

1929 

1930 # Allow some uncertainty for coordinates when calculating partitions. 

1931 pad_arcsec = 1.0 

1932 ids_by_partition = defaultdict(list) 

1933 for object_id in object_ids: 

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

1935 for pixel in pixels: 

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

1937 

1938 statements: list[tuple] = [] 

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

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

1941 statement = cassandra.query.SimpleStatement( 

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

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

1944 ) 

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

1946 

1947 with self._timer( 

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

1949 ) as timer: 

1950 result = cast( 

1951 list[tuple], 

1952 select_concurrent( 

1953 context.session, 

1954 statements, 

1955 "read_named_tuples", 

1956 config.connection_config.read_concurrency, 

1957 ), 

1958 ) 

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

1960 

1961 return result