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

855 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-22 02:00 -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 execute_concurrent, 

76 literal, 

77 quote_id, 

78 select_concurrent, 

79) 

80from .config import ApdbCassandraConfig, ApdbCassandraConnectionConfig, ApdbCassandraTimePartitionRange 

81from .connectionContext import ConnectionContext, DbVersions 

82from .exceptions import CassandraMissingError 

83from .partitioner import Partitioner 

84from .sessionFactory import SessionContext, SessionFactory 

85 

86if TYPE_CHECKING: 

87 from ..apdbMetadata import ApdbMetadata 

88 from ..apdbUpdateRecord import ApdbUpdateRecord 

89 

90_LOG = logging.getLogger(__name__) 

91 

92_MON = MonAgent(__name__) 

93 

94VERSION = VersionTuple(1, 3, 0) 

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

96updated following compatibility rules when schema produced by this code 

97changes. 

98""" 

99 

100 

101class ApdbCassandra(Apdb): 

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

103 

104 Parameters 

105 ---------- 

106 config : `ApdbCassandraConfig` 

107 Configuration object. 

108 """ 

109 

110 def __init__(self, config: ApdbCassandraConfig): 

111 if not CASSANDRA_IMPORTED: 

112 raise CassandraMissingError() 

113 

114 self._config = config 

115 self._keyspace = config.keyspace 

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

117 

118 self._session_factory = SessionFactory(config) 

119 self._connection_context: ConnectionContext | None = None 

120 

121 @property 

122 def _context(self) -> ConnectionContext: 

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

124 if self._connection_context is None: 

125 current_versions = DbVersions( 

126 schema_version=self.schema.schemaVersion(), 

127 code_version=self.apdbImplementationVersion(), 

128 replica_version=ApdbCassandraReplica.apdbReplicaImplementationVersion(), 

129 ) 

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

131 

132 session = self._session_factory.session() 

133 self._connection_context = ConnectionContext( 

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

135 ) 

136 

137 if _LOG.isEnabledFor(logging.DEBUG): 

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

139 

140 return self._connection_context 

141 

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

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

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

145 

146 @classmethod 

147 def apdbImplementationVersion(cls) -> VersionTuple: 

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

149 

150 Returns 

151 ------- 

152 version : `VersionTuple` 

153 Version of the code defined in implementation class. 

154 """ 

155 return VERSION 

156 

157 def getConfig(self) -> ApdbCassandraConfig: 

158 # docstring is inherited from a base class 

159 return self._context.config 

160 

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

162 # docstring is inherited from a base class 

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

164 

165 @classmethod 

166 def init_database( 

167 cls, 

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

169 keyspace: str, 

170 *, 

171 schema_file: str | None = None, 

172 ss_schema_file: str | None = None, 

173 read_sources_months: int | None = None, 

174 read_forced_sources_months: int | None = None, 

175 enable_replica: bool = False, 

176 replica_skips_diaobjects: bool = False, 

177 port: int | None = None, 

178 username: str | None = None, 

179 prefix: str | None = None, 

180 part_pixelization: str | None = None, 

181 part_pix_level: int | None = None, 

182 time_partition_tables: bool = True, 

183 time_partition_start: str | None = None, 

184 time_partition_end: str | None = None, 

185 read_consistency: str | None = None, 

186 write_consistency: str | None = None, 

187 read_timeout: int | None = None, 

188 write_timeout: int | None = None, 

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

190 replication_factor: int | None = None, 

191 drop: bool = False, 

192 table_options: CreateTableOptions | None = None, 

193 ) -> ApdbCassandraConfig: 

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

195 

196 Parameters 

197 ---------- 

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

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

200 keyspace : `str` 

201 Name of the keyspace for APDB tables. 

202 schema_file : `str`, optional 

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

204 specified then default location will be used. 

205 ss_schema_file : `str`, optional 

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

207 specified then default location will be used. 

208 read_sources_months : `int`, optional 

209 Number of months of history to read from DiaSource. 

210 read_forced_sources_months : `int`, optional 

211 Number of months of history to read from DiaForcedSource. 

212 enable_replica : `bool`, optional 

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

214 replica_skips_diaobjects : `bool`, optional 

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

216 ``enable_replica`` is `True`. 

217 port : `int`, optional 

218 Port number to use for Cassandra connections. 

219 username : `str`, optional 

220 User name for Cassandra connections. 

221 prefix : `str`, optional 

222 Optional prefix for all table names. 

223 part_pixelization : `str`, optional 

224 Name of the MOC pixelization used for partitioning. 

225 part_pix_level : `int`, optional 

226 Pixelization level. 

227 time_partition_tables : `bool`, optional 

228 Create per-partition tables. 

229 time_partition_start : `str`, optional 

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

231 format, in TAI. 

232 time_partition_end : `str`, optional 

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

234 format, in TAI. 

235 read_consistency : `str`, optional 

236 Name of the consistency level for read operations. 

237 write_consistency : `str`, optional 

238 Name of the consistency level for write operations. 

239 read_timeout : `int`, optional 

240 Read timeout in seconds. 

241 write_timeout : `int`, optional 

242 Write timeout in seconds. 

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

244 Names of ra/dec columns in DiaObject table. 

245 replication_factor : `int`, optional 

246 Replication factor used when creating new keyspace, if keyspace 

247 already exists its replication factor is not changed. 

248 drop : `bool`, optional 

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

250 table_options : `CreateTableOptions`, optional 

251 Options used when creating Cassandra tables. 

252 

253 Returns 

254 ------- 

255 config : `ApdbCassandraConfig` 

256 Resulting configuration object for a created APDB instance. 

257 """ 

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

259 # changed later in generated config. Check Cassandra driver 

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

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

262 # generated config. 

263 connection_config = ApdbCassandraConnectionConfig( 

264 extra_parameters={ 

265 "idle_heartbeat_interval": 0, 

266 "idle_heartbeat_timeout": 30, 

267 "control_connection_timeout": 100, 

268 }, 

269 ) 

270 config = ApdbCassandraConfig( 

271 contact_points=hosts, 

272 keyspace=keyspace, 

273 enable_replica=enable_replica, 

274 replica_skips_diaobjects=replica_skips_diaobjects, 

275 connection_config=connection_config, 

276 ) 

277 config.partitioning.time_partition_tables = time_partition_tables 

278 if schema_file is not None: 

279 config.schema_file = schema_file 

280 if ss_schema_file is not None: 

281 config.ss_schema_file = ss_schema_file 

282 if read_sources_months is not None: 

283 config.read_sources_months = read_sources_months 

284 if read_forced_sources_months is not None: 

285 config.read_forced_sources_months = read_forced_sources_months 

286 if port is not None: 

287 config.connection_config.port = port 

288 if username is not None: 

289 config.connection_config.username = username 

290 if prefix is not None: 

291 config.prefix = prefix 

292 if part_pixelization is not None: 

293 config.partitioning.part_pixelization = part_pixelization 

294 if part_pix_level is not None: 

295 config.partitioning.part_pix_level = part_pix_level 

296 if time_partition_start is not None: 

297 config.partitioning.time_partition_start = time_partition_start 

298 if time_partition_end is not None: 

299 config.partitioning.time_partition_end = time_partition_end 

300 if read_consistency is not None: 

301 config.connection_config.read_consistency = read_consistency 

302 if write_consistency is not None: 

303 config.connection_config.write_consistency = write_consistency 

304 if read_timeout is not None: 

305 config.connection_config.read_timeout = read_timeout 

306 if write_timeout is not None: 

307 config.connection_config.write_timeout = write_timeout 

308 if ra_dec_columns is not None: 

309 config.ra_dec_columns = ra_dec_columns 

310 

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

312 

313 return config 

314 

315 def get_replica(self) -> ApdbCassandraReplica: 

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

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

318 # we pass reference to self. 

319 return ApdbCassandraReplica(self) 

320 

321 @classmethod 

322 def _makeSchema( 

323 cls, 

324 config: ApdbConfig, 

325 *, 

326 drop: bool = False, 

327 replication_factor: int | None = None, 

328 table_options: CreateTableOptions | None = None, 

329 ) -> None: 

330 # docstring is inherited from a base class 

331 

332 if not isinstance(config, ApdbCassandraConfig): 

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

334 

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

336 

337 with SessionContext(config) as session: 

338 schema = ApdbCassandraSchema( 

339 session=session, 

340 keyspace=config.keyspace, 

341 table_schemas=simple_schema.tableSchemas, 

342 prefix=config.prefix, 

343 time_partition_tables=config.partitioning.time_partition_tables, 

344 enable_replica=config.enable_replica, 

345 replica_skips_diaobjects=config.replica_skips_diaobjects, 

346 ) 

347 

348 # Ask schema to create all tables. 

349 part_range_config: ApdbCassandraTimePartitionRange | None = None 

350 if config.partitioning.time_partition_tables: 

351 partitioner = Partitioner(config) 

352 time_partition_start = astropy.time.Time( 

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

354 ) 

355 time_partition_end = astropy.time.Time( 

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

357 ) 

358 part_range_config = ApdbCassandraTimePartitionRange( 

359 start=partitioner.time_partition(time_partition_start), 

360 end=partitioner.time_partition(time_partition_end), 

361 ) 

362 schema.makeSchema( 

363 drop=drop, 

364 part_range=part_range_config, 

365 replication_factor=replication_factor, 

366 table_options=table_options, 

367 ) 

368 else: 

369 schema.makeSchema( 

370 drop=drop, replication_factor=replication_factor, table_options=table_options 

371 ) 

372 

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

374 metadata = ApdbMetadataCassandra( 

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

376 ) 

377 

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

379 metadata.set( 

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

381 ) 

382 metadata.set( 

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

384 ) 

385 

386 if config.enable_replica: 

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

388 metadata.set( 

389 ConnectionContext.metadataReplicaVersionKey, 

390 str(ApdbCassandraReplica.apdbReplicaImplementationVersion()), 

391 force=True, 

392 ) 

393 

394 # Store frozen part of a configuration in metadata. 

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

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

397 

398 # Store time partition range. 

399 if part_range_config: 

400 part_range_config.save_to_meta(metadata) 

401 

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

403 # docstring is inherited from a base class 

404 context = self._context 

405 config = context.config 

406 

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

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

409 

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

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

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

413 

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

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

416 statements: list[tuple] = [] 

417 for where, params in sp_where: 

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

419 if params: 

420 statement = context.preparer.prepare(full_query) 

421 else: 

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

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

424 # prepare it because it's not reusable. 

425 statement = cassandra.query.SimpleStatement(full_query) 

426 statements.append((statement, params)) 

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

428 

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

430 objects = cast( 

431 pandas.DataFrame, 

432 select_concurrent( 

433 context.session, 

434 statements, 

435 "read_pandas_multi", 

436 config.connection_config.read_concurrency, 

437 ), 

438 ) 

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

440 

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

442 return objects 

443 

444 def getDiaSources( 

445 self, 

446 region: sphgeom.Region, 

447 object_ids: Iterable[int] | None, 

448 visit_time: astropy.time.Time, 

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

450 ) -> pandas.DataFrame | None: 

451 # docstring is inherited from a base class 

452 context = self._context 

453 config = context.config 

454 

455 months = config.read_sources_months 

456 if start_time is None and months == 0: 

457 return None 

458 

459 mjd_end = float(visit_time.tai.mjd) 

460 if start_time is None: 

461 mjd_start = mjd_end - months * 30 

462 else: 

463 mjd_start = float(start_time.tai.mjd) 

464 

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

466 

467 def getDiaForcedSources( 

468 self, 

469 region: sphgeom.Region, 

470 object_ids: Iterable[int] | None, 

471 visit_time: astropy.time.Time, 

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

473 ) -> pandas.DataFrame | None: 

474 # docstring is inherited from a base class 

475 context = self._context 

476 config = context.config 

477 

478 months = config.read_forced_sources_months 

479 if start_time is None and months == 0: 

480 return None 

481 

482 mjd_end = float(visit_time.tai.mjd) 

483 if start_time is None: 

484 mjd_start = mjd_end - months * 30 

485 else: 

486 mjd_start = float(start_time.tai.mjd) 

487 

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

489 

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

491 # docstring is inherited from a base class 

492 context = self._context 

493 config = context.config 

494 

495 if not context.has_dedup_table: 

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

497 

498 if since is None: 

499 # Read last deduplication time from metadata. 

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

501 if dedup_str is not None: 

502 dedup_state = json.loads(dedup_str) 

503 dedup_time_str = dedup_state["dedup_time_iso_tai"] 

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

505 

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

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

508 

509 validity_start_column = self._timestamp_column_name("validityStart") 

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

511 

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

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

514 if since is not None: 

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

516 query += " ALLOW FILTERING" 

517 

518 statement = cassandra.query.SimpleStatement(query) 

519 

520 num_part = config.partitioning.num_part_dedup 

521 statements = [] 

522 for dedup_part in range(num_part): 

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

524 statements.append((statement, params)) 

525 

526 with self._timer( 

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

528 ) as timer: 

529 objects = cast( 

530 pandas.DataFrame, 

531 select_concurrent( 

532 context.session, 

533 statements, 

534 "read_pandas_multi_dedup", 

535 config.connection_config.read_concurrency, 

536 ), 

537 ) 

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

539 

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

541 return objects 

542 

543 def getDiaSourcesForDiaObjects( 

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

545 ) -> pandas.DataFrame: 

546 # docstring is inherited from a base class 

547 context = self._context 

548 config = context.config 

549 

550 # Which tables to query and temporal constraints. 

551 end_time = self._current_time() 

552 tables, temporal_where = context.partitioner.temporal_where( 

553 ApdbTables.DiaSource, 

554 start_time, 

555 end_time, 

556 for_prepare=False, 

557 partitons_range=context.time_partitions_range, 

558 query_per_time_part=False, 

559 ) 

560 if not tables: 

561 warnings.warn( 

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

563 "time partitions." 

564 ) 

565 

566 # Group DiaObjects by partition. 

567 partitioned_object_ids = self._group_dia_objects_by_partition( 

568 context.partitioner, objects, max_dist_arcsec 

569 ) 

570 

571 # Columns to return. 

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

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

574 

575 # Make a bunch of queries. 

576 statements = [] 

577 suffix = "ALLOW FILTERING" 

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

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

580 for table in tables: 

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

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

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

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

585 statements += list( 

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

587 ) 

588 

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

590 

591 with self._timer( 

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

593 ) as timer: 

594 catalog = cast( 

595 pandas.DataFrame, 

596 select_concurrent( 

597 context.session, 

598 statements, 

599 "read_pandas_multi", 

600 config.connection_config.read_concurrency, 

601 ), 

602 ) 

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

604 

605 # precise filtering on midpointMjdTai 

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

607 

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

609 

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

611 return catalog 

612 

613 def containsVisitDetector( 

614 self, 

615 visit: int, 

616 detector: int, 

617 region: sphgeom.Region, 

618 visit_time: astropy.time.Time, 

619 ) -> bool: 

620 # docstring is inherited from a base class 

621 context = self._context 

622 config = context.config 

623 

624 # If ApdbDetectorVisit table exists just check it. 

625 if context.has_visit_detector_table: 

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

627 query = ( 

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

629 ) 

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

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

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

633 

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

635 # store failure earlier tables have higher probability containing 

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

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

638 # production. 

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

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

641 

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

643 # issues add some fuzziness to visit time. 

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

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

646 

647 statements: list[tuple] = [] 

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

649 tables, temporal_where = context.partitioner.temporal_where( 

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

651 ) 

652 for table in tables: 

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

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

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

656 statements += list( 

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

658 ) 

659 

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

661 result = cast( 

662 list[tuple[int] | None], 

663 select_concurrent( 

664 context.session, 

665 statements, 

666 "read_tuples", 

667 config.connection_config.read_concurrency, 

668 ), 

669 ) 

670 return bool(result) 

671 

672 def store( 

673 self, 

674 visit_time: astropy.time.Time, 

675 objects: pandas.DataFrame, 

676 sources: pandas.DataFrame | None = None, 

677 forced_sources: pandas.DataFrame | None = None, 

678 ) -> None: 

679 # docstring is inherited from a base class 

680 context = self._context 

681 config = context.config 

682 

683 if context.has_visit_detector_table: 

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

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

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

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

688 for df in sources, forced_sources: 

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

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

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

692 visit_detector.add((visit, detector)) 

693 

694 if visit_detector: 

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

696 # concurrency. 

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

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

699 for item in visit_detector: 

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

701 

702 objects = self._fix_input_timestamps(objects) 

703 if sources is not None: 

704 sources = self._fix_input_timestamps(sources) 

705 if forced_sources is not None: 

706 forced_sources = self._fix_input_timestamps(forced_sources) 

707 

708 replica_chunk: ReplicaChunk | None = None 

709 if context.schema.replication_enabled: 

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

711 self._storeReplicaChunk(replica_chunk) 

712 

713 # fill region partition column for DiaObjects 

714 objects = self._add_apdb_part(objects) 

715 self._storeDiaObjects(objects, visit_time, replica_chunk) 

716 

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

718 # copy apdb_part column from DiaObjects to DiaSources 

719 sources = self._add_apdb_part(sources) 

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

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

722 

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

724 forced_sources = self._add_apdb_part(forced_sources) 

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

726 

727 def reassignDiaSourcesToDiaObjects( 

728 self, 

729 idMap: Mapping[DiaSourceId, int], 

730 *, 

731 increment_nDiaSources: bool = True, 

732 decrement_nDiaSources: bool = True, 

733 ) -> None: 

734 # docstring is inherited from a base class 

735 context = self._context 

736 config = context.config 

737 

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

739 

740 # Find all DiaSources. 

741 found_sources = self._get_diasource_data( 

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

743 ) 

744 

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

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

747 

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

749 

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

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

752 # send updated values to replica. 

753 current_object_ids = { 

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

755 } 

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

757 new_object_ids = { 

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

759 for source_id, diaObjectId in idMap.items() 

760 } 

761 all_object_ids = new_object_ids | current_object_ids 

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

763 

764 if missing_ids := ( 

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

766 ): 

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

768 

769 update_records: list[ApdbUpdateRecord] = [] 

770 update_order = 0 

771 current_time = self._current_time() 

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

773 

774 # Update DiaSources. 

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

776 statements: list[tuple] = [] 

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

778 source_row = found_sources_by_id[source_id.diaSourceId] 

779 apdb_part = source_row.apdb_part 

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

781 

782 if config.partitioning.time_partition_tables: 

783 statement = context.preparer.prepare( 

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

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

786 ) 

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

788 else: 

789 statement = context.preparer.prepare( 

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

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

792 ) 

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

794 

795 if context.schema.replication_enabled: 

796 update_records.append( 

797 ApdbReassignDiaSourceToDiaObjectRecord( 

798 diaSourceId=source_id.diaSourceId, 

799 ra=source_id.ra, 

800 dec=source_id.dec, 

801 midpointMjdTai=source_id.midpointMjdTai, 

802 diaObjectId=diaObjectId, 

803 update_time_ns=current_time_ns, 

804 update_order=update_order, 

805 ) 

806 ) 

807 update_order += 1 

808 

809 with self._timer( 

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

811 ) as timer: 

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

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

814 

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

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

817 # update records. 

818 if increment_nDiaSources or decrement_nDiaSources: 

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

820 statement = context.preparer.prepare( 

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

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

823 ) 

824 statements = [] 

825 

826 # Calculate increments/decrements for all affected DiaObjects. 

827 increments: Counter = Counter() 

828 if increment_nDiaSources: 

829 increments.update(idMap.values()) 

830 if decrement_nDiaSources: 

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

832 

833 for row in found_objects: 

834 if increments.get(row.diaObjectId): 

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

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

837 

838 # Also send updated values to replica. 

839 if context.schema.replication_enabled: 

840 update_records.append( 

841 ApdbUpdateNDiaSourcesRecord( 

842 diaObjectId=row.diaObjectId, 

843 ra=row.ra, 

844 dec=row.dec, 

845 nDiaSources=nDiaSources, 

846 update_time_ns=current_time_ns, 

847 update_order=update_order, 

848 ) 

849 ) 

850 update_order += 1 

851 

852 if statements: 

853 with self._timer( 

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

855 ) as timer: 

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

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

858 

859 if update_records: 

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

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

862 

863 def setValidityEnd( 

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

865 ) -> int: 

866 # docstring is inherited from a base class 

867 if not objects: 

868 return 0 

869 

870 context = self._context 

871 config = context.config 

872 

873 pad_arcsec = 1.0 

874 partitioned_object_ids = self._group_dia_objects_by_partition( 

875 context.partitioner, objects, pad_arcsec 

876 ) 

877 

878 # Check that all objects exist. 

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

880 statements: list[tuple] = [] 

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

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

883 query = ( 

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

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

886 ) 

887 statement = cassandra.query.SimpleStatement(query) 

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

889 

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

891 records = cast( 

892 list[tuple[int, int]], 

893 select_concurrent( 

894 context.session, 

895 statements, 

896 "read_tuples", 

897 config.connection_config.read_concurrency, 

898 ), 

899 ) 

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

901 

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

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

904 if extra_ids := (found_ids - requested_ids): 

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

906 if raise_on_missing_id: 

907 if missing_ids := (requested_ids - found_ids): 

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

909 

910 # Filter existing records. 

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

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

913 

914 if not objects: 

915 return 0 

916 

917 # Group by partitions again. 

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

919 for apdb_part, diaObjectId in records: 

920 grouped_object_ids[apdb_part].append(diaObjectId) 

921 

922 # Remove all matching rows from DiaObjectLast. 

923 statements = [] 

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

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

926 query = ( 

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

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

929 ) 

930 statement = cassandra.query.SimpleStatement(query) 

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

932 

933 # Also remove from DiaObjectLastToPartition. 

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

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

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

937 statement = cassandra.query.SimpleStatement(query) 

938 statements.append((statement, ())) 

939 

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

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

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

943 

944 # If repication is enabled then send all updates. 

945 if context.schema.replication_enabled: 

946 current_time = self._current_time() 

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

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

949 

950 update_records = [ 

951 ApdbCloseDiaObjectValidityRecord( 

952 diaObjectId=obj.diaObjectId, 

953 ra=obj.ra, 

954 dec=obj.dec, 

955 update_time_ns=current_time_ns, 

956 update_order=index, 

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

958 nDiaSources=None, 

959 ) 

960 for index, obj in enumerate(objects) 

961 ] 

962 

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

964 

965 return len(objects) 

966 

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

968 # docstring is inherited from a base class 

969 context = self._context 

970 

971 if not context.has_dedup_table: 

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

973 

974 if dedup_time is None: 

975 dedup_time = self._current_time() 

976 

977 validity_start_column = self._timestamp_column_name("validityStart") 

978 

979 # Find latest timestamp in deduplication table. 

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

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

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

983 max_value = result.one()[0] 

984 if self._schema.has_mjd_timestamps: 

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

986 else: 

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

988 

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

990 if dedup_time >= max_validity_start: 

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

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

993 else: 

994 dedup_time_value = self._timestamp_column_value(dedup_time) 

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

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

997 

998 # Store dedup time. 

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

1000 data_json = json.dumps(data) 

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

1002 

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

1004 # docstring is inherited from a base class 

1005 context = self._context 

1006 config = context.config 

1007 

1008 now = self._current_time() 

1009 reassign_time_column = self._timestamp_column_name("ssObjectReassocTime") 

1010 reassignTime = self._timestamp_column_value(now) 

1011 

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

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

1014 # primary keys. 

1015 

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

1017 # split it into 1k IDs per query 

1018 selects: list[tuple] = [] 

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

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

1021 selects.append( 

1022 ( 

1023 ( 

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

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

1026 ), 

1027 {}, 

1028 ) 

1029 ) 

1030 

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

1032 result = cast( 

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

1034 select_concurrent( 

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

1036 ), 

1037 ) 

1038 

1039 # Make mapping from source ID to its partition. 

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

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

1042 for row in result: 

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

1044 if row[3] is not None: 

1045 id2chunk_id[row[0]] = row[3] 

1046 

1047 # make sure we know partitions for each ID 

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

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

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

1051 

1052 # Reassign in standard tables 

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

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

1055 apdb_part, apdb_time_part = id2partitions[diaSourceId] 

1056 values: tuple 

1057 if config.partitioning.time_partition_tables: 

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

1059 query = ( 

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

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

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

1063 ) 

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

1065 else: 

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

1067 query = ( 

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

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

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

1071 ) 

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

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

1074 

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

1076 if id2chunk_id: 

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

1078 

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

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

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

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

1083 

1084 def countUnassociatedObjects(self) -> int: 

1085 # docstring is inherited from a base class 

1086 

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

1088 raise NotImplementedError() 

1089 

1090 @property 

1091 def schema(self) -> ApdbSchema: 

1092 # docstring is inherited from a base class 

1093 return self._schema 

1094 

1095 @property 

1096 def metadata(self) -> ApdbMetadata: 

1097 # docstring is inherited from a base class 

1098 context = self._context 

1099 return context.metadata 

1100 

1101 @property 

1102 def admin(self) -> ApdbCassandraAdmin: 

1103 # docstring is inherited from a base class 

1104 return ApdbCassandraAdmin(self) 

1105 

1106 def _getSources( 

1107 self, 

1108 region: sphgeom.Region, 

1109 object_ids: Iterable[int] | None, 

1110 mjd_start: float, 

1111 mjd_end: float, 

1112 table_name: ApdbTables, 

1113 ) -> pandas.DataFrame: 

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

1115 

1116 Parameters 

1117 ---------- 

1118 region : `lsst.sphgeom.Region` 

1119 Spherical region. 

1120 object_ids : 

1121 Collection of DiaObject IDs 

1122 mjd_start : `float` 

1123 Lower bound of time interval. 

1124 mjd_end : `float` 

1125 Upper bound of time interval. 

1126 table_name : `ApdbTables` 

1127 Name of the table. 

1128 

1129 Returns 

1130 ------- 

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

1132 Catalog containing DiaSource records. Empty catalog is returned if 

1133 ``object_ids`` is empty. 

1134 """ 

1135 context = self._context 

1136 config = context.config 

1137 

1138 object_id_set: Set[int] = set() 

1139 if object_ids is not None: 

1140 object_id_set = set(object_ids) 

1141 if len(object_id_set) == 0: 

1142 return self._make_empty_catalog(table_name) 

1143 

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

1145 tables, temporal_where = context.partitioner.temporal_where( 

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

1147 ) 

1148 if not tables: 

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

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

1151 warnings.warn( 

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

1153 ) 

1154 

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

1156 column_names = context.schema.apdbColumnNames(table_name) 

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

1158 

1159 # Build all queries 

1160 statements: list[tuple] = [] 

1161 for table in tables: 

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

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

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

1165 

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

1167 catalog = cast( 

1168 pandas.DataFrame, 

1169 select_concurrent( 

1170 context.session, 

1171 statements, 

1172 "read_pandas_multi", 

1173 config.connection_config.read_concurrency, 

1174 ), 

1175 ) 

1176 timer.add_values( 

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

1178 ) 

1179 

1180 # filter by given object IDs 

1181 if len(object_id_set) > 0: 

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

1183 

1184 # precise filtering on midpointMjdTai 

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

1186 

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

1188 

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

1190 return catalog 

1191 

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

1193 context = self._context 

1194 config = context.config 

1195 

1196 # Cassandra timestamp uses milliseconds since epoch 

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

1198 

1199 # everything goes into a single partition 

1200 partition = 0 

1201 

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

1203 

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

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

1206 if context.has_chunk_sub_partitions: 

1207 columns.append("has_subchunks") 

1208 values.append(True) 

1209 

1210 column_list = ", ".join(columns) 

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

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

1213 

1214 context.session.execute( 

1215 query, 

1216 values, 

1217 timeout=config.connection_config.write_timeout, 

1218 execution_profile="write", 

1219 ) 

1220 

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

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

1223 context = self._context 

1224 config = context.config 

1225 

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

1227 queries = [] 

1228 object_count = 0 

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

1230 id_chunk_list = list(id_chunk) 

1231 query = ( 

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

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

1234 ) 

1235 queries.append((query, ())) 

1236 object_count += len(id_chunk_list) 

1237 

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

1239 data = cast( 

1240 ApdbTableData, 

1241 select_concurrent( 

1242 context.session, 

1243 queries, 

1244 "read_raw_multi", 

1245 config.connection_config.read_concurrency, 

1246 ), 

1247 ) 

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

1249 

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

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

1252 

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

1254 

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

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

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

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

1259 ones. 

1260 """ 

1261 context = self._context 

1262 

1263 # Extract all object IDs. 

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

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

1266 

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

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

1269 new_part = new_partitions.get(oid, old_part) 

1270 if new_part != old_part: 

1271 moved_oids[oid] = (old_part, new_part) 

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

1273 

1274 if moved_oids: 

1275 # Delete old records from DiaObjectLast. 

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

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

1278 statement = context.preparer.prepare(query) 

1279 queries = [] 

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

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

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

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

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

1285 

1286 # Add all new records to the map. 

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

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

1289 statement = context.preparer.prepare(query) 

1290 

1291 queries = [] 

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

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

1294 

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

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

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

1298 

1299 def _storeDiaObjects( 

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

1301 ) -> None: 

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

1303 

1304 Parameters 

1305 ---------- 

1306 objs : `pandas.DataFrame` 

1307 Catalog with DiaObject records 

1308 visit_time : `astropy.time.Time` 

1309 Time of the current visit. 

1310 replica_chunk : `ReplicaChunk` or `None` 

1311 Replica chunk identifier if replication is configured. 

1312 """ 

1313 if len(objs) == 0: 

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

1315 return 

1316 

1317 context = self._context 

1318 config = context.config 

1319 

1320 if context.has_dia_object_last_to_partition: 

1321 self._deleteMovingObjects(objs) 

1322 

1323 validity_start_column = self._timestamp_column_name("validityStart") 

1324 timestamp = self._timestamp_column_value(visit_time) 

1325 

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

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

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

1329 extra_columns[validity_start_column] = timestamp 

1330 

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

1332 

1333 extra_columns[validity_start_column] = timestamp 

1334 visit_time_part = context.partitioner.time_partition(visit_time) 

1335 time_part: int | None = visit_time_part 

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

1337 self._check_time_partitions([visit_time_part], time_partitions_range) 

1338 if not config.partitioning.time_partition_tables: 

1339 extra_columns["apdb_time_part"] = time_part 

1340 time_part = None 

1341 

1342 # Only store DiaObects if not doing replication or explicitly 

1343 # configured to always store them. 

1344 if replica_chunk is None or not config.replica_skips_diaobjects: 

1345 self._storeObjectsPandas( 

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

1347 ) 

1348 

1349 if replica_chunk is not None: 

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

1351 table = ExtraTables.DiaObjectChunks 

1352 if context.has_chunk_sub_partitions: 

1353 table = ExtraTables.DiaObjectChunks2 

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

1355 # that different clients could wrtite to different partitions. 

1356 # This makes it not exactly reproducible. 

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

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

1359 

1360 # Store copy of the records in dedup table. 

1361 if context.has_dedup_table: 

1362 table = ExtraTables.DiaObjectDedup 

1363 extra_columns = { 

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

1365 validity_start_column: timestamp, 

1366 } 

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

1368 

1369 def _storeDiaSources( 

1370 self, 

1371 table_name: ApdbTables, 

1372 sources: pandas.DataFrame, 

1373 replica_chunk: ReplicaChunk | None, 

1374 ) -> int | None: 

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

1376 

1377 Parameters 

1378 ---------- 

1379 table_name : `ApdbTables` 

1380 Table where to store the data. 

1381 sources : `pandas.DataFrame` 

1382 Catalog containing DiaSource records 

1383 visit_time : `astropy.time.Time` 

1384 Time of the current visit. 

1385 replica_chunk : `ReplicaChunk` or `None` 

1386 Replica chunk identifier if replication is configured. 

1387 

1388 Returns 

1389 ------- 

1390 subchunk : `int` or `None` 

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

1392 not enabled ot subchunking is not enabled. 

1393 """ 

1394 context = self._context 

1395 config = context.config 

1396 

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

1398 # as visit_time is not really a visit time. 

1399 tp_sources = sources.copy(deep=False) 

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

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

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

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

1404 if not config.partitioning.time_partition_tables: 

1405 self._storeObjectsPandas(tp_sources, table_name) 

1406 else: 

1407 # Group by time partition 

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

1409 if len(partitions) == 1: 

1410 # Single partition - just save the whole thing. 

1411 time_part = partitions.pop() 

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

1413 else: 

1414 # group by time partition. 

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

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

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

1418 

1419 subchunk: int | None = None 

1420 if replica_chunk is not None: 

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

1422 if context.has_chunk_sub_partitions: 

1423 subchunk = random.randrange(config.replica_sub_chunk_count) 

1424 extra_columns["apdb_replica_subchunk"] = subchunk 

1425 if table_name is ApdbTables.DiaSource: 

1426 extra_table = ExtraTables.DiaSourceChunks2 

1427 else: 

1428 extra_table = ExtraTables.DiaForcedSourceChunks2 

1429 else: 

1430 if table_name is ApdbTables.DiaSource: 

1431 extra_table = ExtraTables.DiaSourceChunks 

1432 else: 

1433 extra_table = ExtraTables.DiaForcedSourceChunks 

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

1435 

1436 return subchunk 

1437 

1438 def _check_time_partitions( 

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

1440 ) -> None: 

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

1442 

1443 Parameters 

1444 ---------- 

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

1446 Time partitions for new data. 

1447 time_partitions_range : `ApdbCassandraTimePartitionRange` 

1448 Currrent time partition range. 

1449 """ 

1450 partitions = set(partitions) 

1451 min_part = min(partitions) 

1452 max_part = max(partitions) 

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

1454 raise ValueError( 

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

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

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

1458 ) 

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

1460 if max_part == time_partitions_range.end: 

1461 warnings.warn( 

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

1463 stacklevel=3, 

1464 ) 

1465 

1466 def _storeDiaSourcesPartitions( 

1467 self, 

1468 sources: pandas.DataFrame, 

1469 visit_time: astropy.time.Time, 

1470 replica_chunk: ReplicaChunk | None, 

1471 subchunk: int | None, 

1472 ) -> None: 

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

1474 

1475 Parameters 

1476 ---------- 

1477 sources : `pandas.DataFrame` 

1478 Catalog containing DiaSource records 

1479 visit_time : `astropy.time.Time` 

1480 Time of the current visit. 

1481 replica_chunk : `ReplicaChunk` or `None` 

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

1483 subchunk : `int` or `None` 

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

1485 sub-chunking is not used. 

1486 """ 

1487 context = self._context 

1488 

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

1490 extra_columns = { 

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

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

1493 } 

1494 if context.has_chunk_sub_partitions: 

1495 extra_columns["apdb_replica_subchunk"] = subchunk 

1496 

1497 self._storeObjectsPandas( 

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

1499 ) 

1500 

1501 def _storeObjectsPandas( 

1502 self, 

1503 records: pandas.DataFrame, 

1504 table_name: ApdbTables | ExtraTables, 

1505 extra_columns: Mapping | None = None, 

1506 time_part: int | None = None, 

1507 ) -> None: 

1508 """Store generic objects. 

1509 

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

1511 

1512 Parameters 

1513 ---------- 

1514 records : `pandas.DataFrame` 

1515 Catalog containing object records 

1516 table_name : `ApdbTables` 

1517 Name of the table as defined in APDB schema. 

1518 extra_columns : `dict`, optional 

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

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

1521 columns exist there. 

1522 time_part : `int`, optional 

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

1524 

1525 Notes 

1526 ----- 

1527 If Pandas catalog contains additional columns not defined in table 

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

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

1530 in a catalog or ``extra_columns``. 

1531 """ 

1532 context = self._context 

1533 

1534 # use extra columns if specified 

1535 if extra_columns is None: 

1536 extra_columns = {} 

1537 extra_fields = list(extra_columns.keys()) 

1538 

1539 # Fields that will come from dataframe. 

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

1541 

1542 column_map = context.schema.getColumnMap(table_name) 

1543 # list of columns (as in felis schema) 

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

1545 fields += extra_fields 

1546 

1547 # check that all partitioning and clustering columns are defined 

1548 partition_columns = context.schema.partitionColumns(table_name) 

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

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

1551 if missing_columns: 

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

1553 

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

1555 qfields_str = ",".join(qfields) 

1556 

1557 batch_size = self._batch_size(table_name) 

1558 

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

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

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

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

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

1564 values = [] 

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

1566 for field in df_fields: 

1567 if field not in column_map: 

1568 continue 

1569 value = getattr(rec, field) 

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

1571 if isinstance(value, pandas.Timestamp): 

1572 value = value.to_pydatetime() 

1573 elif value is pandas.NaT: 

1574 value = None 

1575 else: 

1576 # Assume it's seconds since epoch, Cassandra 

1577 # datetime is in milliseconds 

1578 value = int(value * 1000) 

1579 value = literal(value) 

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

1581 if field in partition_columns: 

1582 partitioning_values[field] = value 

1583 for field in extra_fields: 

1584 value = literal(extra_columns[field]) 

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

1586 if field in partition_columns: 

1587 partitioning_values[field] = value 

1588 

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

1590 values_by_key[key].append(values) 

1591 

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

1593 

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

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

1596 statement = context.preparer.prepare(query) 

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

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

1599 queries = [] 

1600 for key_values in values_by_key.values(): 

1601 for values_chunk in chunk_iterable(key_values, batch_size): 

1602 batch = cassandra.query.BatchStatement() 

1603 for row_values in values_chunk: 

1604 batch.add(statement, row_values) 

1605 queries.append((batch, None)) 

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

1607 

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

1609 with self._timer( 

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

1611 ) as timer: 

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

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

1614 

1615 def _storeUpdateRecords( 

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

1617 ) -> None: 

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

1619 

1620 Parameters 

1621 ---------- 

1622 records : `list` [`ApdbUpdateRecord`] 

1623 Records to store. 

1624 chunk : `ReplicaChunk` 

1625 Replica chunk for these records. 

1626 store_chunk : `bool` 

1627 If True then also store replica chunk. 

1628 

1629 Raises 

1630 ------ 

1631 TypeError 

1632 Raised if replication is not enabled for this instance. 

1633 """ 

1634 context = self._context 

1635 config = context.config 

1636 

1637 if not context.schema.replication_enabled: 

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

1639 

1640 if store_chunk: 

1641 self._storeReplicaChunk(chunk) 

1642 

1643 apdb_replica_chunk = chunk.id 

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

1645 # multiple calls to this method. 

1646 update_unique_id = uuid.uuid4() 

1647 

1648 rows = [] 

1649 for record in records: 

1650 rows.append( 

1651 [ 

1652 apdb_replica_chunk, 

1653 record.update_time_ns, 

1654 record.update_order, 

1655 update_unique_id, 

1656 record.to_json(), 

1657 ] 

1658 ) 

1659 columns = [ 

1660 "apdb_replica_chunk", 

1661 "update_time_ns", 

1662 "update_order", 

1663 "update_unique_id", 

1664 "update_payload", 

1665 ] 

1666 if context.has_chunk_sub_partitions: 

1667 subchunk = random.randrange(config.replica_sub_chunk_count) 

1668 for row in rows: 

1669 row.append(subchunk) 

1670 columns.append("apdb_replica_subchunk") 

1671 

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

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

1674 columns_str = ", ".join(columns) 

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

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

1677 

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

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

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

1681 

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

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

1684 DataFrame. 

1685 

1686 Parameters 

1687 ---------- 

1688 df : `pandas.DataFrame` 

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

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

1691 

1692 Returns 

1693 ------- 

1694 df : `pandas.DataFrame` 

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

1696 for ra/dec coordinates. 

1697 

1698 Notes 

1699 ----- 

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

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

1702 is returned. 

1703 """ 

1704 context = self._context 

1705 config = context.config 

1706 

1707 # Calculate pixelization index for every record. 

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

1709 ra_col, dec_col = config.ra_dec_columns 

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

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

1712 idx = context.partitioner.pixel(uv3d) 

1713 apdb_part[i] = idx 

1714 df = df.copy() 

1715 df["apdb_part"] = apdb_part 

1716 return df 

1717 

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

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

1720 

1721 Parameters 

1722 ---------- 

1723 table_name : `ApdbTables` 

1724 Name of the table. 

1725 

1726 Returns 

1727 ------- 

1728 catalog : `pandas.DataFrame` 

1729 An empty catalog. 

1730 """ 

1731 table = self.schema.tableSchemas[table_name] 

1732 

1733 data = { 

1734 columnDef.name: pandas.Series(dtype=self.schema.column_dtype(columnDef.datatype)) 

1735 for columnDef in table.columns 

1736 } 

1737 return pandas.DataFrame(data) 

1738 

1739 def _combine_where( 

1740 self, 

1741 prefix: str, 

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

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

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

1745 suffix: str | None = None, 

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

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

1748 of statements to execute. 

1749 

1750 Parameters 

1751 ---------- 

1752 prefix : `str` 

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

1754 "SELECT * from Table" 

1755 """ 

1756 context = self._context 

1757 

1758 # If lists are empty use special sentinels. 

1759 if not where1: 

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

1761 if not where2: 

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

1763 

1764 for expr1, params1 in where1: 

1765 for expr2, params2 in where2: 

1766 full_query = prefix 

1767 wheres = [] 

1768 params = params1 + params2 

1769 if expr1: 

1770 wheres.append(expr1) 

1771 if expr2: 

1772 wheres.append(expr2) 

1773 if where3: 

1774 wheres.append(where3[0]) 

1775 params += where3[1] 

1776 if wheres: 

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

1778 if suffix: 

1779 full_query += " " + suffix 

1780 if params: 

1781 statement = context.preparer.prepare(full_query) 

1782 else: 

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

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

1785 # trying to prepare it. 

1786 statement = cassandra.query.SimpleStatement(full_query) 

1787 yield (statement, params) 

1788 

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

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

1791 type. 

1792 

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

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

1795 drop timezone. 

1796 """ 

1797 # Find all columns with aware timestamps. 

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

1799 for column in columns: 

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

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

1802 return df 

1803 

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

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

1806 context = self._context 

1807 config = context.config 

1808 

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

1810 batch_size = 65_535 

1811 if 0 < config.batch_statement_limit < batch_size: 

1812 batch_size = config.batch_statement_limit 

1813 if config.batch_size_limit > 0: 

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

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

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

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

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

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

1820 # ignore. 

1821 row_size = context.schema.table_row_size(table) 

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

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

1824 return batch_size 

1825 

1826 def _group_dia_objects_by_partition( 

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

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

1829 """Group DiaObjects by partition. 

1830 

1831 Parameters 

1832 ---------- 

1833 partitioner : `Partitioner` 

1834 Objects which knows how to partition things. 

1835 objects : `list` [`DiaObjectId`] 

1836 Collection of objects to partition. 

1837 pad_arcsec : `float` 

1838 Additional padding around object position. 

1839 

1840 Returns 

1841 ------- 

1842 grouped_objects 

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

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

1845 """ 

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

1847 for obj_id in objects: 

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

1849 for pixel in partitions: 

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

1851 return partitioned_object_ids 

1852 

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

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

1855 return self._schema.timestamp_column_name(column) 

1856 

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

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

1859 if self._schema.has_mjd_timestamps: 

1860 return float(time.tai.mjd) 

1861 else: 

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

1863 

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

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

1866 records as a list of named tuples. 

1867 """ 

1868 context = self._context 

1869 config = context.config 

1870 partitioner = context.partitioner 

1871 

1872 columns = ("diaSourceId",) + columns 

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

1874 

1875 # Allow some uncertainty for coordinates and time when calculating 

1876 # partitions. 

1877 statements: list[tuple] = [] 

1878 pad_arcsec = 1.0 

1879 pad_time_day = 10 / (24 * 3600) 

1880 for source_id in source_ids: 

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

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

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

1884 

1885 tables, temporal_where = partitioner.temporal_where( 

1886 ApdbTables.DiaSource, 

1887 source_id.midpointMjdTai - pad_time_day, 

1888 source_id.midpointMjdTai + pad_time_day, 

1889 for_prepare=True, 

1890 partitons_range=context.time_partitions_range, 

1891 query_per_time_part=True, 

1892 ) 

1893 

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

1895 

1896 for table in tables: 

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

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

1899 

1900 with self._timer( 

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

1902 ) as timer: 

1903 result = cast( 

1904 list[tuple], 

1905 select_concurrent( 

1906 context.session, 

1907 statements, 

1908 "read_named_tuples", 

1909 config.connection_config.read_concurrency, 

1910 ), 

1911 ) 

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

1913 

1914 return result 

1915 

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

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

1918 all records as a list of named tuples. 

1919 """ 

1920 context = self._context 

1921 config = context.config 

1922 partitioner = context.partitioner 

1923 

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

1925 columns = ("diaObjectId",) + columns 

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

1927 

1928 # Allow some uncertainty for coordinates when calculating partitions. 

1929 pad_arcsec = 1.0 

1930 ids_by_partition = defaultdict(list) 

1931 for object_id in object_ids: 

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

1933 for pixel in pixels: 

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

1935 

1936 statements: list[tuple] = [] 

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

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

1939 statement = cassandra.query.SimpleStatement( 

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

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

1942 ) 

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

1944 

1945 with self._timer( 

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

1947 ) as timer: 

1948 result = cast( 

1949 list[tuple], 

1950 select_concurrent( 

1951 context.session, 

1952 statements, 

1953 "read_named_tuples", 

1954 config.connection_config.read_concurrency, 

1955 ), 

1956 ) 

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

1958 

1959 return result