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

605 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 03:35 -0700

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 logging 

28import random 

29import warnings 

30from collections import defaultdict 

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

32from typing import TYPE_CHECKING, Any, cast 

33 

34import numpy as np 

35import pandas 

36 

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

38# but ApdbCassandra cannot be instantiated. 

39try: 

40 import cassandra 

41 import cassandra.query 

42 from cassandra.query import UNSET_VALUE 

43 

44 CASSANDRA_IMPORTED = True 

45except ImportError: 

46 CASSANDRA_IMPORTED = False 

47 

48import astropy.time 

49import felis.datamodel 

50 

51from lsst import sphgeom 

52from lsst.utils.iteration import chunk_iterable 

53 

54from ..apdb import Apdb, ApdbConfig 

55from ..apdbConfigFreezer import ApdbConfigFreezer 

56from ..apdbReplica import ApdbTableData, ReplicaChunk 

57from ..apdbSchema import ApdbSchema, ApdbTables 

58from ..monitor import MonAgent 

59from ..schema_model import Table 

60from ..timer import Timer 

61from ..versionTuple import IncompatibleVersionError, VersionTuple 

62from .apdbCassandraAdmin import ApdbCassandraAdmin 

63from .apdbCassandraReplica import ApdbCassandraReplica 

64from .apdbCassandraSchema import ApdbCassandraSchema, CreateTableOptions, ExtraTables 

65from .apdbMetadataCassandra import ApdbMetadataCassandra 

66from .cassandra_utils import ( 

67 execute_concurrent, 

68 literal, 

69 quote_id, 

70 select_concurrent, 

71) 

72from .config import ApdbCassandraConfig, ApdbCassandraConnectionConfig, ApdbCassandraTimePartitionRange 

73from .connectionContext import ConnectionContext, DbVersions 

74from .exceptions import CassandraMissingError 

75from .partitioner import Partitioner 

76from .sessionFactory import SessionContext, SessionFactory 

77 

78if TYPE_CHECKING: 

79 from ..apdbMetadata import ApdbMetadata 

80 

81_LOG = logging.getLogger(__name__) 

82 

83_MON = MonAgent(__name__) 

84 

85VERSION = VersionTuple(1, 1, 0) 

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

87updated following compatibility rules when schema produced by this code 

88changes. 

89""" 

90 

91 

92class ApdbCassandra(Apdb): 

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

94 

95 Parameters 

96 ---------- 

97 config : `ApdbCassandraConfig` 

98 Configuration object. 

99 """ 

100 

101 def __init__(self, config: ApdbCassandraConfig): 

102 if not CASSANDRA_IMPORTED: 

103 raise CassandraMissingError() 

104 

105 self._config = config 

106 self._keyspace = config.keyspace 

107 self._schema = ApdbSchema(config.schema_file, config.schema_name) 

108 

109 self._session_factory = SessionFactory(config) 

110 self._connection_context: ConnectionContext | None = None 

111 

112 @property 

113 def _context(self) -> ConnectionContext: 

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

115 if self._connection_context is None: 

116 session = self._session_factory.session() 

117 self._connection_context = ConnectionContext(session, self._config, self._schema.tableSchemas) 

118 

119 # Check version compatibility 

120 current_versions = DbVersions( 

121 schema_version=self._schema.schemaVersion(), 

122 code_version=self.apdbImplementationVersion(), 

123 replica_version=( 

124 ApdbCassandraReplica.apdbReplicaImplementationVersion() 

125 if self._connection_context.config.enable_replica 

126 else None 

127 ), 

128 ) 

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

130 self._versionCheck(current_versions, self._connection_context.db_versions) 

131 

132 if _LOG.isEnabledFor(logging.DEBUG): 

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

134 

135 return self._connection_context 

136 

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

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

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

140 

141 def _versionCheck(self, current_versions: DbVersions, db_versions: DbVersions) -> None: 

142 """Check schema version compatibility.""" 

143 if not current_versions.schema_version.checkCompatibility(db_versions.schema_version): 

144 raise IncompatibleVersionError( 

145 f"Configured schema version {current_versions.schema_version} " 

146 f"is not compatible with database version {db_versions.schema_version}" 

147 ) 

148 if not current_versions.code_version.checkCompatibility(db_versions.code_version): 

149 raise IncompatibleVersionError( 

150 f"Current code version {current_versions.code_version} " 

151 f"is not compatible with database version {db_versions.code_version}" 

152 ) 

153 

154 # Check replica code version only if replica is enabled. 

155 match current_versions.replica_version, db_versions.replica_version: 

156 case None, None: 

157 pass 

158 case VersionTuple() as current, VersionTuple() as stored: 

159 if not current.checkCompatibility(stored): 

160 raise IncompatibleVersionError( 

161 f"Current replication code version {current} " 

162 f"is not compatible with database version {stored}" 

163 ) 

164 case _: 

165 raise IncompatibleVersionError( 

166 f"Current replication code version {current_versions.replica_version} " 

167 f"is not compatible with database version {db_versions.replica_version}" 

168 ) 

169 

170 @classmethod 

171 def apdbImplementationVersion(cls) -> VersionTuple: 

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

173 

174 Returns 

175 ------- 

176 version : `VersionTuple` 

177 Version of the code defined in implementation class. 

178 """ 

179 return VERSION 

180 

181 def getConfig(self) -> ApdbCassandraConfig: 

182 # docstring is inherited from a base class 

183 return self._context.config 

184 

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

186 # docstring is inherited from a base class 

187 return self._schema.tableSchemas.get(table) 

188 

189 @classmethod 

190 def init_database( 

191 cls, 

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

193 keyspace: str, 

194 *, 

195 schema_file: str | None = None, 

196 schema_name: str | None = None, 

197 read_sources_months: int | None = None, 

198 read_forced_sources_months: int | None = None, 

199 enable_replica: bool = False, 

200 replica_skips_diaobjects: bool = False, 

201 port: int | None = None, 

202 username: str | None = None, 

203 prefix: str | None = None, 

204 part_pixelization: str | None = None, 

205 part_pix_level: int | None = None, 

206 time_partition_tables: bool = True, 

207 time_partition_start: str | None = None, 

208 time_partition_end: str | None = None, 

209 read_consistency: str | None = None, 

210 write_consistency: str | None = None, 

211 read_timeout: int | None = None, 

212 write_timeout: int | None = None, 

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

214 replication_factor: int | None = None, 

215 drop: bool = False, 

216 table_options: CreateTableOptions | None = None, 

217 ) -> ApdbCassandraConfig: 

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

219 

220 Parameters 

221 ---------- 

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

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

224 keyspace : `str` 

225 Name of the keyspace for APDB tables. 

226 schema_file : `str`, optional 

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

228 specified then default location will be used. 

229 schema_name : `str`, optional 

230 Name of the schema in YAML configuration file. If not specified 

231 then default name will be used. 

232 read_sources_months : `int`, optional 

233 Number of months of history to read from DiaSource. 

234 read_forced_sources_months : `int`, optional 

235 Number of months of history to read from DiaForcedSource. 

236 enable_replica : `bool`, optional 

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

238 replica_skips_diaobjects : `bool`, optional 

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

240 ``enable_replica`` is `True`. 

241 port : `int`, optional 

242 Port number to use for Cassandra connections. 

243 username : `str`, optional 

244 User name for Cassandra connections. 

245 prefix : `str`, optional 

246 Optional prefix for all table names. 

247 part_pixelization : `str`, optional 

248 Name of the MOC pixelization used for partitioning. 

249 part_pix_level : `int`, optional 

250 Pixelization level. 

251 time_partition_tables : `bool`, optional 

252 Create per-partition tables. 

253 time_partition_start : `str`, optional 

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

255 format, in TAI. 

256 time_partition_end : `str`, optional 

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

258 format, in TAI. 

259 read_consistency : `str`, optional 

260 Name of the consistency level for read operations. 

261 write_consistency : `str`, optional 

262 Name of the consistency level for write operations. 

263 read_timeout : `int`, optional 

264 Read timeout in seconds. 

265 write_timeout : `int`, optional 

266 Write timeout in seconds. 

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

268 Names of ra/dec columns in DiaObject table. 

269 replication_factor : `int`, optional 

270 Replication factor used when creating new keyspace, if keyspace 

271 already exists its replication factor is not changed. 

272 drop : `bool`, optional 

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

274 table_options : `CreateTableOptions`, optional 

275 Options used when creating Cassandra tables. 

276 

277 Returns 

278 ------- 

279 config : `ApdbCassandraConfig` 

280 Resulting configuration object for a created APDB instance. 

281 """ 

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

283 # changed later in generated config. Check Cassandra driver 

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

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

286 # generated config. 

287 connection_config = ApdbCassandraConnectionConfig( 

288 extra_parameters={ 

289 "idle_heartbeat_interval": 0, 

290 "idle_heartbeat_timeout": 30, 

291 "control_connection_timeout": 100, 

292 }, 

293 ) 

294 config = ApdbCassandraConfig( 

295 contact_points=hosts, 

296 keyspace=keyspace, 

297 enable_replica=enable_replica, 

298 replica_skips_diaobjects=replica_skips_diaobjects, 

299 connection_config=connection_config, 

300 ) 

301 config.partitioning.time_partition_tables = time_partition_tables 

302 if schema_file is not None: 

303 config.schema_file = schema_file 

304 if schema_name is not None: 

305 config.schema_name = schema_name 

306 if read_sources_months is not None: 

307 config.read_sources_months = read_sources_months 

308 if read_forced_sources_months is not None: 

309 config.read_forced_sources_months = read_forced_sources_months 

310 if port is not None: 

311 config.connection_config.port = port 

312 if username is not None: 

313 config.connection_config.username = username 

314 if prefix is not None: 

315 config.prefix = prefix 

316 if part_pixelization is not None: 

317 config.partitioning.part_pixelization = part_pixelization 

318 if part_pix_level is not None: 

319 config.partitioning.part_pix_level = part_pix_level 

320 if time_partition_start is not None: 

321 config.partitioning.time_partition_start = time_partition_start 

322 if time_partition_end is not None: 

323 config.partitioning.time_partition_end = time_partition_end 

324 if read_consistency is not None: 

325 config.connection_config.read_consistency = read_consistency 

326 if write_consistency is not None: 

327 config.connection_config.write_consistency = write_consistency 

328 if read_timeout is not None: 

329 config.connection_config.read_timeout = read_timeout 

330 if write_timeout is not None: 

331 config.connection_config.write_timeout = write_timeout 

332 if ra_dec_columns is not None: 

333 config.ra_dec_columns = ra_dec_columns 

334 

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

336 

337 return config 

338 

339 def get_replica(self) -> ApdbCassandraReplica: 

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

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

342 # we pass reference to self. 

343 return ApdbCassandraReplica(self) 

344 

345 @classmethod 

346 def _makeSchema( 

347 cls, 

348 config: ApdbConfig, 

349 *, 

350 drop: bool = False, 

351 replication_factor: int | None = None, 

352 table_options: CreateTableOptions | None = None, 

353 ) -> None: 

354 # docstring is inherited from a base class 

355 

356 if not isinstance(config, ApdbCassandraConfig): 

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

358 

359 simple_schema = ApdbSchema(config.schema_file, config.schema_name) 

360 

361 with SessionContext(config) as session: 

362 schema = ApdbCassandraSchema( 

363 session=session, 

364 keyspace=config.keyspace, 

365 table_schemas=simple_schema.tableSchemas, 

366 prefix=config.prefix, 

367 time_partition_tables=config.partitioning.time_partition_tables, 

368 enable_replica=config.enable_replica, 

369 replica_skips_diaobjects=config.replica_skips_diaobjects, 

370 ) 

371 

372 # Ask schema to create all tables. 

373 part_range_config: ApdbCassandraTimePartitionRange | None = None 

374 if config.partitioning.time_partition_tables: 

375 partitioner = Partitioner(config) 

376 time_partition_start = astropy.time.Time( 

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

378 ) 

379 time_partition_end = astropy.time.Time( 

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

381 ) 

382 part_range_config = ApdbCassandraTimePartitionRange( 

383 start=partitioner.time_partition(time_partition_start), 

384 end=partitioner.time_partition(time_partition_end), 

385 ) 

386 schema.makeSchema( 

387 drop=drop, 

388 part_range=part_range_config, 

389 replication_factor=replication_factor, 

390 table_options=table_options, 

391 ) 

392 else: 

393 schema.makeSchema( 

394 drop=drop, replication_factor=replication_factor, table_options=table_options 

395 ) 

396 

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

398 metadata = ApdbMetadataCassandra( 

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

400 ) 

401 

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

403 metadata.set( 

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

405 ) 

406 metadata.set( 

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

408 ) 

409 

410 if config.enable_replica: 

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

412 metadata.set( 

413 ConnectionContext.metadataReplicaVersionKey, 

414 str(ApdbCassandraReplica.apdbReplicaImplementationVersion()), 

415 force=True, 

416 ) 

417 

418 # Store frozen part of a configuration in metadata. 

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

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

421 

422 # Store time partition range. 

423 if part_range_config: 

424 part_range_config.save_to_meta(metadata) 

425 

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

427 # docstring is inherited from a base class 

428 context = self._context 

429 config = context.config 

430 

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

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

433 

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

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

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

437 

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

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

440 statements: list[tuple] = [] 

441 for where, params in sp_where: 

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

443 if params: 

444 statement = context.preparer.prepare(full_query) 

445 else: 

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

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

448 # prepare it because it's not reusable. 

449 statement = cassandra.query.SimpleStatement(full_query) 

450 statements.append((statement, params)) 

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

452 

453 with _MON.context_tags({"table": "DiaObject"}): 

454 _MON.add_record( 

455 "select_query_stats", values={"num_sp_part": num_sp_part, "num_queries": len(statements)} 

456 ) 

457 with self._timer("select_time") as timer: 

458 objects = cast( 

459 pandas.DataFrame, 

460 select_concurrent( 

461 context.session, 

462 statements, 

463 "read_pandas_multi", 

464 config.connection_config.read_concurrency, 

465 ), 

466 ) 

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

468 

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

470 return objects 

471 

472 def getDiaSources( 

473 self, 

474 region: sphgeom.Region, 

475 object_ids: Iterable[int] | None, 

476 visit_time: astropy.time.Time, 

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

478 ) -> pandas.DataFrame | None: 

479 # docstring is inherited from a base class 

480 context = self._context 

481 config = context.config 

482 

483 months = config.read_sources_months 

484 if start_time is None and months == 0: 

485 return None 

486 

487 mjd_end = float(visit_time.tai.mjd) 

488 if start_time is None: 

489 mjd_start = mjd_end - months * 30 

490 else: 

491 mjd_start = float(start_time.tai.mjd) 

492 

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

494 

495 def getDiaForcedSources( 

496 self, 

497 region: sphgeom.Region, 

498 object_ids: Iterable[int] | None, 

499 visit_time: astropy.time.Time, 

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

501 ) -> pandas.DataFrame | None: 

502 # docstring is inherited from a base class 

503 context = self._context 

504 config = context.config 

505 

506 months = config.read_forced_sources_months 

507 if start_time is None and months == 0: 

508 return None 

509 

510 mjd_end = float(visit_time.tai.mjd) 

511 if start_time is None: 

512 mjd_start = mjd_end - months * 30 

513 else: 

514 mjd_start = float(start_time.tai.mjd) 

515 

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

517 

518 def containsVisitDetector( 

519 self, 

520 visit: int, 

521 detector: int, 

522 region: sphgeom.Region, 

523 visit_time: astropy.time.Time, 

524 ) -> bool: 

525 # docstring is inherited from a base class 

526 context = self._context 

527 config = context.config 

528 

529 # If ApdbDetectorVisit table exists just check it. 

530 if context.has_visit_detector_table: 

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

532 query = ( 

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

534 ) 

535 with self._timer("contains_visit_detector_time"): 

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

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

538 

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

540 # store failure earlier tables have higher probability containing 

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

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

543 # production. 

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

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

546 

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

548 # issues add some fuzzines to visit time. 

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

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

551 

552 statements: list[tuple] = [] 

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

554 tables, temporal_where = context.partitioner.temporal_where( 

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

556 ) 

557 for table in tables: 

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

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

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

561 statements += list( 

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

563 ) 

564 

565 with self._timer("contains_visit_detector_time"): 

566 result = cast( 

567 list[tuple[int] | None], 

568 select_concurrent( 

569 context.session, 

570 statements, 

571 "read_tuples", 

572 config.connection_config.read_concurrency, 

573 ), 

574 ) 

575 return bool(result) 

576 

577 def getSSObjects(self) -> pandas.DataFrame: 

578 # docstring is inherited from a base class 

579 context = self._context 

580 

581 tableName = context.schema.tableName(ApdbTables.SSObject) 

582 query = f'SELECT * from "{self._keyspace}"."{tableName}"' 

583 

584 objects = None 

585 with self._timer("select_time", tags={"table": "SSObject"}) as timer: 

586 result = context.session.execute(query, execution_profile="read_pandas") 

587 objects = result._current_rows 

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

589 

590 _LOG.debug("found %s SSObjects", objects.shape[0]) 

591 return objects 

592 

593 def store( 

594 self, 

595 visit_time: astropy.time.Time, 

596 objects: pandas.DataFrame, 

597 sources: pandas.DataFrame | None = None, 

598 forced_sources: pandas.DataFrame | None = None, 

599 ) -> None: 

600 # docstring is inherited from a base class 

601 context = self._context 

602 config = context.config 

603 

604 if context.has_visit_detector_table: 

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

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

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

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

609 for df in sources, forced_sources: 

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

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

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

613 visit_detector.add((visit, detector)) 

614 

615 if visit_detector: 

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

617 # concurrency. 

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

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

620 for item in visit_detector: 

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

622 

623 objects = self._fix_input_timestamps(objects) 

624 if sources is not None: 

625 sources = self._fix_input_timestamps(sources) 

626 if forced_sources is not None: 

627 forced_sources = self._fix_input_timestamps(forced_sources) 

628 

629 replica_chunk: ReplicaChunk | None = None 

630 if context.schema.replication_enabled: 

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

632 self._storeReplicaChunk(replica_chunk, visit_time) 

633 

634 # fill region partition column for DiaObjects 

635 objects = self._add_apdb_part(objects) 

636 self._storeDiaObjects(objects, visit_time, replica_chunk) 

637 

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

639 # copy apdb_part column from DiaObjects to DiaSources 

640 sources = self._add_apdb_part(sources) 

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

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

643 

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

645 forced_sources = self._add_apdb_part(forced_sources) 

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

647 

648 def storeSSObjects(self, objects: pandas.DataFrame) -> None: 

649 # docstring is inherited from a base class 

650 objects = self._fix_input_timestamps(objects) 

651 self._storeObjectsPandas(objects, ApdbTables.SSObject) 

652 

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

654 # docstring is inherited from a base class 

655 context = self._context 

656 config = context.config 

657 

658 if self._schema.has_mjd_timestamps: 

659 reassign_time_column = "ssObjectReassocTimeMjdTai" 

660 reassignTime = float(astropy.time.Time.now().tai.mjd) 

661 else: 

662 reassign_time_column = "ssObjectReassocTime" 

663 # Current time as milliseconds since epoch. 

664 reassignTime = int(datetime.datetime.now(tz=datetime.UTC).timestamp() * 1000) 

665 

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

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

668 # primary keys. 

669 

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

671 # split it into 1k IDs per query 

672 selects: list[tuple] = [] 

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

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

675 selects.append( 

676 ( 

677 ( 

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

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

680 ), 

681 {}, 

682 ) 

683 ) 

684 

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

686 result = cast( 

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

688 select_concurrent( 

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

690 ), 

691 ) 

692 

693 # Make mapping from source ID to its partition. 

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

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

696 for row in result: 

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

698 if row[3] is not None: 

699 id2chunk_id[row[0]] = row[3] 

700 

701 # make sure we know partitions for each ID 

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

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

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

705 

706 # Reassign in standard tables 

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

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

709 apdb_part, apdb_time_part = id2partitions[diaSourceId] 

710 values: tuple 

711 if config.partitioning.time_partition_tables: 

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

713 query = ( 

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

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

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

717 ) 

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

719 else: 

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

721 query = ( 

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

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

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

725 ) 

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

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

728 

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

730 if id2chunk_id: 

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

732 

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

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

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

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

737 

738 def dailyJob(self) -> None: 

739 # docstring is inherited from a base class 

740 pass 

741 

742 def countUnassociatedObjects(self) -> int: 

743 # docstring is inherited from a base class 

744 

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

746 raise NotImplementedError() 

747 

748 @property 

749 def metadata(self) -> ApdbMetadata: 

750 # docstring is inherited from a base class 

751 context = self._context 

752 return context.metadata 

753 

754 @property 

755 def admin(self) -> ApdbCassandraAdmin: 

756 # docstring is inherited from a base class 

757 return ApdbCassandraAdmin(self) 

758 

759 def _getSources( 

760 self, 

761 region: sphgeom.Region, 

762 object_ids: Iterable[int] | None, 

763 mjd_start: float, 

764 mjd_end: float, 

765 table_name: ApdbTables, 

766 ) -> pandas.DataFrame: 

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

768 

769 Parameters 

770 ---------- 

771 region : `lsst.sphgeom.Region` 

772 Spherical region. 

773 object_ids : 

774 Collection of DiaObject IDs 

775 mjd_start : `float` 

776 Lower bound of time interval. 

777 mjd_end : `float` 

778 Upper bound of time interval. 

779 table_name : `ApdbTables` 

780 Name of the table. 

781 

782 Returns 

783 ------- 

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

785 Catalog containing DiaSource records. Empty catalog is returned if 

786 ``object_ids`` is empty. 

787 """ 

788 context = self._context 

789 config = context.config 

790 

791 object_id_set: Set[int] = set() 

792 if object_ids is not None: 

793 object_id_set = set(object_ids) 

794 if len(object_id_set) == 0: 

795 return self._make_empty_catalog(table_name) 

796 

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

798 tables, temporal_where = context.partitioner.temporal_where( 

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

800 ) 

801 if not tables: 

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

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

804 warnings.warn( 

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

806 ) 

807 

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

809 column_names = context.schema.apdbColumnNames(table_name) 

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

811 

812 # Build all queries 

813 statements: list[tuple] = [] 

814 for table in tables: 

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

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

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

818 

819 with _MON.context_tags({"table": table_name.name}): 

820 _MON.add_record( 

821 "select_query_stats", values={"num_sp_part": num_sp_part, "num_queries": len(statements)} 

822 ) 

823 with self._timer("select_time") as timer: 

824 catalog = cast( 

825 pandas.DataFrame, 

826 select_concurrent( 

827 context.session, 

828 statements, 

829 "read_pandas_multi", 

830 config.connection_config.read_concurrency, 

831 ), 

832 ) 

833 timer.add_values(row_count_from_db=len(catalog)) 

834 

835 # filter by given object IDs 

836 if len(object_id_set) > 0: 

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

838 

839 # precise filtering on midpointMjdTai 

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

841 

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

843 

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

845 return catalog 

846 

847 def _storeReplicaChunk(self, replica_chunk: ReplicaChunk, visit_time: astropy.time.Time) -> None: 

848 context = self._context 

849 config = context.config 

850 

851 # Cassandra timestamp uses milliseconds since epoch 

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

853 

854 # everything goes into a single partition 

855 partition = 0 

856 

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

858 

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

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

861 if context.has_chunk_sub_partitions: 

862 columns.append("has_subchunks") 

863 values.append(True) 

864 

865 column_list = ", ".join(columns) 

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

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

868 

869 context.session.execute( 

870 query, 

871 values, 

872 timeout=config.connection_config.write_timeout, 

873 execution_profile="write", 

874 ) 

875 

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

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

878 context = self._context 

879 config = context.config 

880 

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

882 queries = [] 

883 object_count = 0 

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

885 id_chunk_list = list(id_chunk) 

886 query = ( 

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

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

889 ) 

890 queries.append((query, ())) 

891 object_count += len(id_chunk_list) 

892 

893 with self._timer("query_object_last_partitions") as timer: 

894 data = cast( 

895 ApdbTableData, 

896 select_concurrent( 

897 context.session, 

898 queries, 

899 "read_raw_multi", 

900 config.connection_config.read_concurrency, 

901 ), 

902 ) 

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

904 

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

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

907 

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

909 

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

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

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

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

914 ones. 

915 """ 

916 context = self._context 

917 

918 # Extract all object IDs. 

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

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

921 

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

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

924 new_part = new_partitions.get(oid, old_part) 

925 if new_part != old_part: 

926 moved_oids[oid] = (old_part, new_part) 

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

928 

929 if moved_oids: 

930 # Delete old records from DiaObjectLast. 

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

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

933 statement = context.preparer.prepare(query) 

934 queries = [] 

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

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

937 with self._timer("delete_object_last") as timer: 

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

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

940 

941 # Add all new records to the map. 

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

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

944 statement = context.preparer.prepare(query) 

945 

946 queries = [] 

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

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

949 

950 with self._timer("update_object_last_partition") as timer: 

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

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

953 

954 def _storeDiaObjects( 

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

956 ) -> None: 

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

958 

959 Parameters 

960 ---------- 

961 objs : `pandas.DataFrame` 

962 Catalog with DiaObject records 

963 visit_time : `astropy.time.Time` 

964 Time of the current visit. 

965 replica_chunk : `ReplicaChunk` or `None` 

966 Replica chunk identifier if replication is configured. 

967 """ 

968 if len(objs) == 0: 

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

970 return 

971 

972 context = self._context 

973 config = context.config 

974 

975 if context.has_dia_object_last_to_partition: 

976 self._deleteMovingObjects(objs) 

977 

978 timestamp: float | datetime.datetime 

979 if self._schema.has_mjd_timestamps: 

980 validity_start_column = "validityStartMjdTai" 

981 timestamp = float(visit_time.tai.mjd) 

982 else: 

983 validity_start_column = "validityStart" 

984 timestamp = visit_time.datetime 

985 

986 self._storeObjectsPandas(objs, ApdbTables.DiaObjectLast) 

987 

988 extra_columns: dict[str, Any] = {validity_start_column: timestamp} 

989 visit_time_part = context.partitioner.time_partition(visit_time) 

990 time_part: int | None = visit_time_part 

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

992 self._check_time_partitions([visit_time_part], time_partitions_range) 

993 if not config.partitioning.time_partition_tables: 

994 extra_columns["apdb_time_part"] = time_part 

995 time_part = None 

996 

997 # Only store DiaObects if not doing replication or explicitly 

998 # configured to always store them. 

999 if replica_chunk is None or not config.replica_skips_diaobjects: 

1000 self._storeObjectsPandas( 

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

1002 ) 

1003 

1004 if replica_chunk is not None: 

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

1006 table = ExtraTables.DiaObjectChunks 

1007 if context.has_chunk_sub_partitions: 

1008 table = ExtraTables.DiaObjectChunks2 

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

1010 # that different clients could wrtite to different partitions. 

1011 # This makes it not exactly reproducible. 

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

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

1014 

1015 def _storeDiaSources( 

1016 self, 

1017 table_name: ApdbTables, 

1018 sources: pandas.DataFrame, 

1019 replica_chunk: ReplicaChunk | None, 

1020 ) -> int | None: 

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

1022 

1023 Parameters 

1024 ---------- 

1025 table_name : `ApdbTables` 

1026 Table where to store the data. 

1027 sources : `pandas.DataFrame` 

1028 Catalog containing DiaSource records 

1029 visit_time : `astropy.time.Time` 

1030 Time of the current visit. 

1031 replica_chunk : `ReplicaChunk` or `None` 

1032 Replica chunk identifier if replication is configured. 

1033 

1034 Returns 

1035 ------- 

1036 subchunk : `int` or `None` 

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

1038 not enabled ot subchunking is not enabled. 

1039 """ 

1040 context = self._context 

1041 config = context.config 

1042 

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

1044 # as visit_time is not really a visit time. 

1045 tp_sources = sources.copy(deep=False) 

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

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

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

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

1050 if not config.partitioning.time_partition_tables: 

1051 self._storeObjectsPandas(tp_sources, table_name) 

1052 else: 

1053 # Group by time partition 

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

1055 if len(partitions) == 1: 

1056 # Single partition - just save the whole thing. 

1057 time_part = partitions.pop() 

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

1059 else: 

1060 # group by time partition. 

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

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

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

1064 

1065 subchunk: int | None = None 

1066 if replica_chunk is not None: 

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

1068 if context.has_chunk_sub_partitions: 

1069 subchunk = random.randrange(config.replica_sub_chunk_count) 

1070 extra_columns["apdb_replica_subchunk"] = subchunk 

1071 if table_name is ApdbTables.DiaSource: 

1072 extra_table = ExtraTables.DiaSourceChunks2 

1073 else: 

1074 extra_table = ExtraTables.DiaForcedSourceChunks2 

1075 else: 

1076 if table_name is ApdbTables.DiaSource: 

1077 extra_table = ExtraTables.DiaSourceChunks 

1078 else: 

1079 extra_table = ExtraTables.DiaForcedSourceChunks 

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

1081 

1082 return subchunk 

1083 

1084 def _check_time_partitions( 

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

1086 ) -> None: 

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

1088 

1089 Parameters 

1090 ---------- 

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

1092 Time partitions for new data. 

1093 time_partitions_range : `ApdbCassandraTimePartitionRange` 

1094 Currrent time partition range. 

1095 """ 

1096 partitions = set(partitions) 

1097 min_part = min(partitions) 

1098 max_part = max(partitions) 

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

1100 raise ValueError( 

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

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

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

1104 ) 

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

1106 if max_part == time_partitions_range.end: 

1107 warnings.warn( 

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

1109 stacklevel=3, 

1110 ) 

1111 

1112 def _storeDiaSourcesPartitions( 

1113 self, 

1114 sources: pandas.DataFrame, 

1115 visit_time: astropy.time.Time, 

1116 replica_chunk: ReplicaChunk | None, 

1117 subchunk: int | None, 

1118 ) -> None: 

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

1120 

1121 Parameters 

1122 ---------- 

1123 sources : `pandas.DataFrame` 

1124 Catalog containing DiaSource records 

1125 visit_time : `astropy.time.Time` 

1126 Time of the current visit. 

1127 replica_chunk : `ReplicaChunk` or `None` 

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

1129 subchunk : `int` or `None` 

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

1131 sub-chunking is not used. 

1132 """ 

1133 context = self._context 

1134 

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

1136 extra_columns = { 

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

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

1139 } 

1140 if context.has_chunk_sub_partitions: 

1141 extra_columns["apdb_replica_subchunk"] = subchunk 

1142 

1143 self._storeObjectsPandas( 

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

1145 ) 

1146 

1147 def _storeObjectsPandas( 

1148 self, 

1149 records: pandas.DataFrame, 

1150 table_name: ApdbTables | ExtraTables, 

1151 extra_columns: Mapping | None = None, 

1152 time_part: int | None = None, 

1153 ) -> None: 

1154 """Store generic objects. 

1155 

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

1157 

1158 Parameters 

1159 ---------- 

1160 records : `pandas.DataFrame` 

1161 Catalog containing object records 

1162 table_name : `ApdbTables` 

1163 Name of the table as defined in APDB schema. 

1164 extra_columns : `dict`, optional 

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

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

1167 columns exist there. 

1168 time_part : `int`, optional 

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

1170 

1171 Notes 

1172 ----- 

1173 If Pandas catalog contains additional columns not defined in table 

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

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

1176 in a catalog or ``extra_columns``. 

1177 """ 

1178 context = self._context 

1179 

1180 # use extra columns if specified 

1181 if extra_columns is None: 

1182 extra_columns = {} 

1183 extra_fields = list(extra_columns.keys()) 

1184 

1185 # Fields that will come from dataframe. 

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

1187 

1188 column_map = context.schema.getColumnMap(table_name) 

1189 # list of columns (as in felis schema) 

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

1191 fields += extra_fields 

1192 

1193 # check that all partitioning and clustering columns are defined 

1194 partition_columns = context.schema.partitionColumns(table_name) 

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

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

1197 if missing_columns: 

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

1199 

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

1201 qfields_str = ",".join(qfields) 

1202 

1203 batch_size = self._batch_size(table_name) 

1204 

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

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

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

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

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

1210 values = [] 

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

1212 for field in df_fields: 

1213 if field not in column_map: 

1214 continue 

1215 value = getattr(rec, field) 

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

1217 if isinstance(value, pandas.Timestamp): 

1218 value = value.to_pydatetime() 

1219 elif value is pandas.NaT: 

1220 value = None 

1221 else: 

1222 # Assume it's seconds since epoch, Cassandra 

1223 # datetime is in milliseconds 

1224 value = int(value * 1000) 

1225 value = literal(value) 

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

1227 if field in partition_columns: 

1228 partitioning_values[field] = value 

1229 for field in extra_fields: 

1230 value = literal(extra_columns[field]) 

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

1232 if field in partition_columns: 

1233 partitioning_values[field] = value 

1234 

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

1236 values_by_key[key].append(values) 

1237 

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

1239 

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

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

1242 statement = context.preparer.prepare(query) 

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

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

1245 queries = [] 

1246 for key_values in values_by_key.values(): 

1247 for values_chunk in chunk_iterable(key_values, batch_size): 

1248 batch = cassandra.query.BatchStatement() 

1249 for row_values in values_chunk: 

1250 batch.add(statement, row_values) 

1251 queries.append((batch, None)) 

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

1253 

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

1255 with self._timer("insert_time", tags={"table": table_name.name}) as timer: 

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

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

1258 

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

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

1261 DataFrame. 

1262 

1263 Parameters 

1264 ---------- 

1265 df : `pandas.DataFrame` 

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

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

1268 

1269 Returns 

1270 ------- 

1271 df : `pandas.DataFrame` 

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

1273 for ra/dec coordinates. 

1274 

1275 Notes 

1276 ----- 

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

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

1279 is returned. 

1280 """ 

1281 context = self._context 

1282 config = context.config 

1283 

1284 # Calculate pixelization index for every record. 

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

1286 ra_col, dec_col = config.ra_dec_columns 

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

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

1289 idx = context.partitioner.pixel(uv3d) 

1290 apdb_part[i] = idx 

1291 df = df.copy() 

1292 df["apdb_part"] = apdb_part 

1293 return df 

1294 

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

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

1297 

1298 Parameters 

1299 ---------- 

1300 table_name : `ApdbTables` 

1301 Name of the table. 

1302 

1303 Returns 

1304 ------- 

1305 catalog : `pandas.DataFrame` 

1306 An empty catalog. 

1307 """ 

1308 table = self._schema.tableSchemas[table_name] 

1309 

1310 data = { 

1311 columnDef.name: pandas.Series(dtype=self._schema.column_dtype(columnDef.datatype)) 

1312 for columnDef in table.columns 

1313 } 

1314 return pandas.DataFrame(data) 

1315 

1316 def _combine_where( 

1317 self, 

1318 prefix: str, 

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

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

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

1322 suffix: str | None = None, 

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

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

1325 of statements to execute. 

1326 

1327 Parameters 

1328 ---------- 

1329 prefix : `str` 

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

1331 "SELECT * from Table" 

1332 """ 

1333 context = self._context 

1334 

1335 # If lists are empty use special sentinels. 

1336 if not where1: 

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

1338 if not where2: 

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

1340 

1341 for expr1, params1 in where1: 

1342 for expr2, params2 in where2: 

1343 full_query = prefix 

1344 wheres = [] 

1345 params = params1 + params2 

1346 if expr1: 

1347 wheres.append(expr1) 

1348 if expr2: 

1349 wheres.append(expr2) 

1350 if where3: 

1351 wheres.append(where3[0]) 

1352 params += where3[1] 

1353 if wheres: 

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

1355 if suffix: 

1356 full_query += " " + suffix 

1357 if params: 

1358 statement = context.preparer.prepare(full_query) 

1359 else: 

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

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

1362 # trying to prepare it. 

1363 statement = cassandra.query.SimpleStatement(full_query) 

1364 yield (statement, params) 

1365 

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

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

1368 type. 

1369 

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

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

1372 drop timezone. 

1373 """ 

1374 # Find all columns with aware timestamps. 

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

1376 for column in columns: 

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

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

1379 return df 

1380 

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

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

1383 context = self._context 

1384 config = context.config 

1385 

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

1387 batch_size = 65_535 

1388 if 0 < config.batch_statement_limit < batch_size: 

1389 batch_size = config.batch_statement_limit 

1390 if config.batch_size_limit > 0: 

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

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

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

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

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

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

1397 # ignore. 

1398 row_size = context.schema.table_row_size(table) 

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

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

1401 return batch_size