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

926 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-09 17:49 +0000

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, 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 ApdbWithdrawDiaForcedSourceRecord, 

65 ApdbWithdrawDiaSourceRecord, 

66) 

67from ..monitor import MonAgent 

68from ..recordIds import DiaForcedSourceId, DiaObjectId, DiaSourceId 

69from ..schema_model import Table 

70from ..timer import Timer 

71from ..versionTuple import VersionTuple 

72from .apdbCassandraAdmin import ApdbCassandraAdmin 

73from .apdbCassandraReplica import ApdbCassandraReplica 

74from .apdbCassandraSchema import ApdbCassandraSchema, CreateTableOptions, ExtraTables 

75from .apdbMetadataCassandra import ApdbMetadataCassandra 

76from .cassandra_utils import ( 

77 ApdbCassandraTableData, 

78 execute_concurrent, 

79 literal, 

80 select_concurrent, 

81) 

82from .config import ApdbCassandraConfig, ApdbCassandraConnectionConfig, ApdbCassandraTimePartitionRange 

83from .connectionContext import ConnectionContext, DbVersions 

84from .exceptions import CassandraMissingError 

85from .partitioner import Partitioner 

86from .queries import Column as C # noqa: N817 

87from .queries import ColumnExpr, Delete, Insert, QExpr, Select, Update 

88from .sessionFactory import SessionContext, SessionFactory 

89 

90if TYPE_CHECKING: 

91 from ..apdbMetadata import ApdbMetadata 

92 from ..apdbUpdateRecord import ApdbUpdateRecord 

93 

94_LOG = logging.getLogger(__name__) 

95 

96_MON = MonAgent(__name__) 

97 

98VERSION = VersionTuple(1, 3, 0) 

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

100updated following compatibility rules when schema produced by this code 

101changes. 

102""" 

103 

104 

105class ApdbCassandra(Apdb): 

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

107 

108 Parameters 

109 ---------- 

110 config : `ApdbCassandraConfig` 

111 Configuration object. 

112 """ 

113 

114 def __init__(self, config: ApdbCassandraConfig): 

115 if not CASSANDRA_IMPORTED: 

116 raise CassandraMissingError() 

117 

118 self._config = config 

119 self._keyspace = config.keyspace 

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

121 

122 self._session_factory = SessionFactory(config) 

123 self._connection_context: ConnectionContext | None = None 

124 

125 def close(self) -> None: 

126 # docstring is inherited from a base class 

127 self._session_factory.close() 

128 

129 @property 

130 def _context(self) -> ConnectionContext: 

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

132 if self._connection_context is None: 

133 current_versions = DbVersions( 

134 schema_version=self.schema.schemaVersion(), 

135 code_version=self.apdbImplementationVersion(), 

136 replica_version=ApdbCassandraReplica.apdbReplicaImplementationVersion(), 

137 ) 

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

139 

140 session = self._session_factory.session() 

141 self._connection_context = ConnectionContext( 

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

143 ) 

144 

145 if _LOG.isEnabledFor(logging.DEBUG): 

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

147 

148 return self._connection_context 

149 

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

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

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

153 

154 @classmethod 

155 def apdbImplementationVersion(cls) -> VersionTuple: 

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

157 

158 Returns 

159 ------- 

160 version : `VersionTuple` 

161 Version of the code defined in implementation class. 

162 """ 

163 return VERSION 

164 

165 def getConfig(self) -> ApdbCassandraConfig: 

166 # docstring is inherited from a base class 

167 return self._context.config 

168 

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

170 # docstring is inherited from a base class 

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

172 

173 @classmethod 

174 def init_database( 

175 cls, 

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

177 keyspace: str, 

178 *, 

179 schema_file: str | None = None, 

180 ss_schema_file: str | None = None, 

181 read_sources_months: int | None = None, 

182 read_forced_sources_months: int | None = None, 

183 enable_replica: bool = False, 

184 replica_skips_diaobjects: bool = False, 

185 port: int | None = None, 

186 username: str | None = None, 

187 dbauth_alias: str | None = None, 

188 prefix: str | None = None, 

189 part_pixelization: str | None = None, 

190 part_pix_level: int | None = None, 

191 time_partition_tables: bool = True, 

192 time_partition_start: str | None = None, 

193 time_partition_end: str | None = None, 

194 read_consistency: str | None = None, 

195 write_consistency: str | None = None, 

196 read_timeout: int | None = None, 

197 write_timeout: int | None = None, 

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

199 replication_factor: int | None = None, 

200 drop: bool = False, 

201 table_options: CreateTableOptions | None = None, 

202 ) -> ApdbCassandraConfig: 

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

204 

205 Parameters 

206 ---------- 

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

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

209 keyspace : `str` 

210 Name of the keyspace for APDB tables. 

211 schema_file : `str`, optional 

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

213 specified then default location will be used. 

214 ss_schema_file : `str`, optional 

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

216 specified then default location will be used. 

217 read_sources_months : `int`, optional 

218 Number of months of history to read from DiaSource. 

219 read_forced_sources_months : `int`, optional 

220 Number of months of history to read from DiaForcedSource. 

221 enable_replica : `bool`, optional 

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

223 replica_skips_diaobjects : `bool`, optional 

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

225 ``enable_replica`` is `True`. 

226 port : `int`, optional 

227 Port number to use for Cassandra connections. 

228 username : `str`, optional 

229 User name for Cassandra connections. 

230 dbauth_alias : `str`, optional 

231 If specified then this string will be used to as a host name when 

232 checking credentials in db-auth.yaml in addition to regular host 

233 names in contact_points. For example if 

234 dbauth_alias='pp_apdb_prod_cluster' then the entry 

235 'cassandra://pp_apdb_prod_cluster/' will match. Port number should 

236 not be used in that entry. Alias has higher priority than host 

237 names. 

238 prefix : `str`, optional 

239 Optional prefix for all table names. 

240 part_pixelization : `str`, optional 

241 Name of the MOC pixelization used for partitioning. 

242 part_pix_level : `int`, optional 

243 Pixelization level. 

244 time_partition_tables : `bool`, optional 

245 Create per-partition tables. 

246 time_partition_start : `str`, optional 

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

248 format, in TAI. 

249 time_partition_end : `str`, optional 

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

251 format, in TAI. 

252 read_consistency : `str`, optional 

253 Name of the consistency level for read operations. 

254 write_consistency : `str`, optional 

255 Name of the consistency level for write operations. 

256 read_timeout : `int`, optional 

257 Read timeout in seconds. 

258 write_timeout : `int`, optional 

259 Write timeout in seconds. 

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

261 Names of ra/dec columns in DiaObject table. 

262 replication_factor : `int`, optional 

263 Replication factor used when creating new keyspace, if keyspace 

264 already exists its replication factor is not changed. 

265 drop : `bool`, optional 

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

267 table_options : `CreateTableOptions`, optional 

268 Options used when creating Cassandra tables. 

269 

270 Returns 

271 ------- 

272 config : `ApdbCassandraConfig` 

273 Resulting configuration object for a created APDB instance. 

274 """ 

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

276 # changed later in generated config. Check Cassandra driver 

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

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

279 # generated config. 

280 connection_config = ApdbCassandraConnectionConfig( 

281 extra_parameters={ 

282 "idle_heartbeat_interval": 0, 

283 "idle_heartbeat_timeout": 30, 

284 "control_connection_timeout": 100, 

285 }, 

286 ) 

287 config = ApdbCassandraConfig( 

288 contact_points=hosts, 

289 keyspace=keyspace, 

290 enable_replica=enable_replica, 

291 replica_skips_diaobjects=replica_skips_diaobjects, 

292 connection_config=connection_config, 

293 ) 

294 config.partitioning.time_partition_tables = time_partition_tables 

295 if schema_file is not None: 

296 config.schema_file = schema_file 

297 if ss_schema_file is not None: 

298 config.ss_schema_file = ss_schema_file 

299 if read_sources_months is not None: 

300 config.read_sources_months = read_sources_months 

301 if read_forced_sources_months is not None: 

302 config.read_forced_sources_months = read_forced_sources_months 

303 if port is not None: 

304 config.connection_config.port = port 

305 if username is not None: 

306 config.connection_config.username = username 

307 if dbauth_alias is not None: 

308 config.connection_config.dbauth_alias = dbauth_alias 

309 if prefix is not None: 

310 config.prefix = prefix 

311 if part_pixelization is not None: 

312 config.partitioning.part_pixelization = part_pixelization 

313 if part_pix_level is not None: 

314 config.partitioning.part_pix_level = part_pix_level 

315 if time_partition_start is not None: 

316 config.partitioning.time_partition_start = time_partition_start 

317 if time_partition_end is not None: 

318 config.partitioning.time_partition_end = time_partition_end 

319 if read_consistency is not None: 

320 config.connection_config.read_consistency = read_consistency 

321 if write_consistency is not None: 

322 config.connection_config.write_consistency = write_consistency 

323 if read_timeout is not None: 

324 config.connection_config.read_timeout = read_timeout 

325 if write_timeout is not None: 

326 config.connection_config.write_timeout = write_timeout 

327 if ra_dec_columns is not None: 

328 config.ra_dec_columns = ra_dec_columns 

329 

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

331 

332 return config 

333 

334 def get_replica(self) -> ApdbCassandraReplica: 

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

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

337 # we pass reference to self. 

338 return ApdbCassandraReplica(self) 

339 

340 @classmethod 

341 def _makeSchema( 

342 cls, 

343 config: ApdbConfig, 

344 *, 

345 drop: bool = False, 

346 replication_factor: int | None = None, 

347 table_options: CreateTableOptions | None = None, 

348 ) -> None: 

349 # docstring is inherited from a base class 

350 

351 if not isinstance(config, ApdbCassandraConfig): 

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

353 

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

355 

356 with SessionContext(config) as session: 

357 schema = ApdbCassandraSchema( 

358 session=session, 

359 keyspace=config.keyspace, 

360 table_schemas=simple_schema.tableSchemas, 

361 prefix=config.prefix, 

362 time_partition_tables=config.partitioning.time_partition_tables, 

363 enable_replica=config.enable_replica, 

364 replica_skips_diaobjects=config.replica_skips_diaobjects, 

365 ) 

366 

367 # Ask schema to create all tables. 

368 part_range_config: ApdbCassandraTimePartitionRange | None = None 

369 if config.partitioning.time_partition_tables: 

370 partitioner = Partitioner(config) 

371 time_partition_start = astropy.time.Time( 

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

373 ) 

374 time_partition_end = astropy.time.Time( 

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

376 ) 

377 part_range_config = ApdbCassandraTimePartitionRange( 

378 start=partitioner.time_partition(time_partition_start), 

379 end=partitioner.time_partition(time_partition_end), 

380 ) 

381 schema.makeSchema( 

382 drop=drop, 

383 part_range=part_range_config, 

384 replication_factor=replication_factor, 

385 table_options=table_options, 

386 ) 

387 else: 

388 schema.makeSchema( 

389 drop=drop, replication_factor=replication_factor, table_options=table_options 

390 ) 

391 

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

393 metadata = ApdbMetadataCassandra( 

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

395 ) 

396 

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

398 metadata.set( 

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

400 ) 

401 metadata.set( 

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

403 ) 

404 

405 if config.enable_replica: 

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

407 metadata.set( 

408 ConnectionContext.metadataReplicaVersionKey, 

409 str(ApdbCassandraReplica.apdbReplicaImplementationVersion()), 

410 force=True, 

411 ) 

412 

413 # Store frozen part of a configuration in metadata. 

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

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

416 

417 # Store time partition range. 

418 if part_range_config: 

419 part_range_config.save_to_meta(metadata) 

420 

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

422 # docstring is inherited from a base class 

423 context = self._context 

424 config = context.config 

425 

426 sp_where, num_sp_part = context.partitioner.spatial_where(region) 

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

428 

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

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

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

432 query = Select(self._keyspace, table_name, column_names) 

433 statements: list[tuple] = [] 

434 for where_clause in sp_where: 

435 full_query = query.where(where_clause) 

436 statements.append(context.stmt_factory.with_params(full_query, prepare=True)) 

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

438 

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

440 raw_objects = cast( 

441 ApdbCassandraTableData, 

442 select_concurrent( 

443 context.session, 

444 statements, 

445 "read_raw_multi", 

446 config.connection_config.read_concurrency, 

447 ), 

448 ) 

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

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

451 

452 self._fix_null_columns(ApdbTables.DiaObjectLast, objects) 

453 

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

455 return objects 

456 

457 def getDiaSources( 

458 self, 

459 region: sphgeom.Region, 

460 object_ids: Iterable[int] | None, 

461 visit_time: astropy.time.Time, 

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

463 ) -> pandas.DataFrame | None: 

464 # docstring is inherited from a base class 

465 context = self._context 

466 config = context.config 

467 

468 months = config.read_sources_months 

469 if start_time is None and months == 0: 

470 return None 

471 

472 mjd_end = float(visit_time.tai.mjd) 

473 if start_time is None: 

474 mjd_start = mjd_end - months * 30 

475 else: 

476 mjd_start = float(start_time.tai.mjd) 

477 

478 sources = self._getSources(region, object_ids, mjd_start, mjd_end, ApdbTables.DiaSource) 

479 

480 self._fix_null_columns(ApdbTables.DiaSource, sources) 

481 

482 return sources 

483 

484 def getDiaForcedSources( 

485 self, 

486 region: sphgeom.Region, 

487 object_ids: Iterable[int] | None, 

488 visit_time: astropy.time.Time, 

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

490 ) -> pandas.DataFrame | None: 

491 # docstring is inherited from a base class 

492 context = self._context 

493 config = context.config 

494 

495 months = config.read_forced_sources_months 

496 if start_time is None and months == 0: 

497 return None 

498 

499 mjd_end = float(visit_time.tai.mjd) 

500 if start_time is None: 

501 mjd_start = mjd_end - months * 30 

502 else: 

503 mjd_start = float(start_time.tai.mjd) 

504 

505 sources = self._getSources(region, object_ids, mjd_start, mjd_end, ApdbTables.DiaForcedSource) 

506 

507 self._fix_null_columns(ApdbTables.DiaForcedSource, sources) 

508 

509 return sources 

510 

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

512 # docstring is inherited from a base class 

513 context = self._context 

514 config = context.config 

515 

516 if not context.has_dedup_table: 

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

518 

519 if since is None: 

520 # Read last deduplication time from metadata. 

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

522 if dedup_str is not None: 

523 dedup_state = json.loads(dedup_str) 

524 dedup_time_str = dedup_state["dedup_time_iso_tai"] 

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

526 

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

528 

529 validity_start_column = self._timestamp_column_name("validityStart") 

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

531 

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

533 query = Select(self._keyspace, table_name, column_names, extra_clause="ALLOW FILTERING") 

534 query = query.where(C("dedup_part") == 0) 

535 if since is not None: 

536 query = query.where(C(validity_start_column) >= 0) 

537 

538 statement = context.stmt_factory(query, prepare=False) 

539 

540 num_part = config.partitioning.num_part_dedup 

541 statements = [] 

542 for dedup_part in range(num_part): 

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

544 statements.append((statement, params)) 

545 

546 with self._timer( 

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

548 ) as timer: 

549 objects_raw = cast( 

550 ApdbCassandraTableData, 

551 select_concurrent( 

552 context.session, 

553 statements, 

554 "read_raw_multi_dedup", 

555 config.connection_config.read_concurrency, 

556 ), 

557 ) 

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

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

560 

561 # DiaObjectDedup columns are copies of DiaObject columns. 

562 self._fix_null_columns(ApdbTables.DiaObject, objects) 

563 

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

565 return objects 

566 

567 def getDiaSourcesForDiaObjects( 

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

569 ) -> pandas.DataFrame: 

570 # docstring is inherited from a base class 

571 context = self._context 

572 config = context.config 

573 

574 # Which tables to query and temporal constraints. 

575 end_time = self._current_time() 

576 tables, temporal_where = context.partitioner.temporal_where( 

577 ApdbTables.DiaSource, 

578 start_time, 

579 end_time, 

580 partitons_range=context.time_partitions_range, 

581 query_per_time_part=False, 

582 ) 

583 if not tables: 

584 warnings.warn( 

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

586 "time partitions." 

587 ) 

588 

589 # Group DiaObjects by partition. 

590 partitioned_object_ids = self._group_dia_objects_by_partition( 

591 context.partitioner, objects, max_dist_arcsec 

592 ) 

593 

594 # Columns to return. 

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

596 

597 # Make a bunch of queries. 

598 statements = [] 

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

600 spatial_where = [C("apdb_part") == apdb_part] 

601 for table in tables: 

602 query = Select(self._keyspace, table, column_names, extra_clause="ALLOW FILTERING") 

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

604 id_where = C("diaObjectId").in_(id_chunk) 

605 for clause in QExpr.combine(spatial_where, temporal_where, extra=id_where): 

606 statements.append( 

607 context.stmt_factory.with_params(query.where(clause), prepare=False) 

608 ) 

609 

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

611 

612 with self._timer( 

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

614 ) as timer: 

615 table_data_raw = cast( 

616 ApdbCassandraTableData, 

617 select_concurrent( 

618 context.session, 

619 statements, 

620 "read_raw_multi", 

621 config.connection_config.read_concurrency, 

622 ), 

623 ) 

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

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

626 

627 # precise filtering on midpointMjdTai 

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

629 

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

631 

632 self._fix_null_columns(ApdbTables.DiaSource, catalog) 

633 

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

635 return catalog 

636 

637 def containsVisitDetector( 

638 self, 

639 visit: int, 

640 detector: int, 

641 region: sphgeom.Region | None = None, 

642 visit_time: astropy.time.Time | None = None, 

643 ) -> bool: 

644 # docstring is inherited from a base class 

645 context = self._context 

646 

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

648 query = Select(self._keyspace, table_name, [ColumnExpr("count(*)")]) 

649 query = query.where((C("visit") == visit) & (C("detector") == detector)) 

650 stmt, params = context.stmt_factory.with_params(query, prepare=False) 

651 

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

653 result = context.session.execute(stmt, params) 

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

655 

656 def store( 

657 self, 

658 visit_time: astropy.time.Time, 

659 objects: pandas.DataFrame, 

660 sources: pandas.DataFrame | None = None, 

661 forced_sources: pandas.DataFrame | None = None, 

662 ) -> None: 

663 # docstring is inherited from a base class 

664 context = self._context 

665 config = context.config 

666 

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

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

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

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

671 for df in sources, forced_sources: 

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

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

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

675 visit_detector.add((visit, detector)) 

676 

677 if visit_detector: 

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

679 # concurrency. 

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

681 query = Insert(self._keyspace, table_name, ("visit", "detector")) 

682 stmt = context.stmt_factory(query) 

683 for item in visit_detector: 

684 context.session.execute(stmt, item, execution_profile="write") 

685 

686 objects = self._fix_input_timestamps(objects) 

687 if sources is not None: 

688 sources = self._fix_input_timestamps(sources) 

689 if forced_sources is not None: 

690 forced_sources = self._fix_input_timestamps(forced_sources) 

691 

692 replica_chunk: ReplicaChunk | None = None 

693 if context.schema.replication_enabled: 

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

695 self._storeReplicaChunk(replica_chunk) 

696 

697 # fill region partition column for DiaObjects 

698 objects = self._add_apdb_part(objects) 

699 self._storeDiaObjects(objects, visit_time, replica_chunk) 

700 

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

702 # copy apdb_part column from DiaObjects to DiaSources 

703 sources = self._add_apdb_part(sources) 

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

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

706 

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

708 forced_sources = self._add_apdb_part(forced_sources) 

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

710 

711 def reassignDiaSourcesToDiaObjects( 

712 self, 

713 idMap: Mapping[DiaSourceId, int], 

714 *, 

715 increment_nDiaSources: bool = True, 

716 decrement_nDiaSources: bool = True, 

717 ) -> None: 

718 # docstring is inherited from a base class 

719 context = self._context 

720 config = context.config 

721 

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

723 

724 # Find all DiaSources. 

725 found_sources = self._get_diasource_data( 

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

727 ) 

728 

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

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

731 

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

733 

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

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

736 # send updated values to replica. 

737 current_object_ids = { 

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

739 } 

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

741 new_object_ids = { 

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

743 for source_id, diaObjectId in idMap.items() 

744 } 

745 all_object_ids = new_object_ids | current_object_ids 

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

747 

748 if missing_ids := ( 

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

750 ): 

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

752 

753 update_records: list[ApdbUpdateRecord] = [] 

754 update_order = 0 

755 current_time = self._current_time() 

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

757 

758 # Update DiaSources. 

759 statements: list[tuple] = [] 

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

761 source_row = found_sources_by_id[source_id.diaSourceId] 

762 apdb_part = source_row.apdb_part 

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

764 

765 if config.partitioning.time_partition_tables: 

766 table_name = context.schema.tableName(ApdbTables.DiaSource, time_part) 

767 update = ( 

768 Update(self._keyspace, table_name) 

769 .values(C("diaObjectId").update(diaObjectId)) 

770 .where(C("apdb_part") == apdb_part) 

771 .where(C("diaSourceId") == source_id.diaSourceId) 

772 ) 

773 else: 

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

775 update = ( 

776 Update(self._keyspace, table_name) 

777 .values(C("diaObjectId").update(diaObjectId)) 

778 .where(C("apdb_part") == apdb_part) 

779 .where(C("apdb_time_part") == time_part) 

780 .where(C("diaSourceId") == source_id.diaSourceId) 

781 ) 

782 statements.append(context.stmt_factory.with_params(update, prepare=True)) 

783 

784 if context.schema.replication_enabled: 

785 update_records.append( 

786 ApdbReassignDiaSourceToDiaObjectRecord( 

787 diaSourceId=source_id.diaSourceId, 

788 ra=source_id.ra, 

789 dec=source_id.dec, 

790 midpointMjdTai=source_id.midpointMjdTai, 

791 diaObjectId=diaObjectId, 

792 update_time_ns=current_time_ns, 

793 update_order=update_order, 

794 ) 

795 ) 

796 update_order += 1 

797 

798 with self._timer( 

799 "update_time", tags={"table": "DiaSource", "method": "reassignDiaSourcesToDiaObjects"} 

800 ) as timer: 

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

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

803 

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

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

806 # update records. 

807 if increment_nDiaSources or decrement_nDiaSources: 

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

809 update = ( 

810 Update(self._keyspace, table_name) 

811 .values(C("nDiaSources").update(-1)) 

812 .where(C("apdb_part") == -1) 

813 .where(C("diaObjectId") == -1) 

814 ) 

815 statement = context.stmt_factory(update, prepare=True) 

816 statements = [] 

817 

818 # Calculate increments/decrements for all affected DiaObjects. 

819 increments: Counter = Counter() 

820 if increment_nDiaSources: 

821 increments.update(idMap.values()) 

822 if decrement_nDiaSources: 

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

824 

825 for row in found_objects: 

826 if increments.get(row.diaObjectId): 

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

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

829 

830 # Also send updated values to replica. 

831 if context.schema.replication_enabled: 

832 update_records.append( 

833 ApdbUpdateNDiaSourcesRecord( 

834 diaObjectId=row.diaObjectId, 

835 ra=row.ra, 

836 dec=row.dec, 

837 nDiaSources=nDiaSources, 

838 update_time_ns=current_time_ns, 

839 update_order=update_order, 

840 ) 

841 ) 

842 update_order += 1 

843 

844 if statements: 

845 with self._timer( 

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

847 ) as timer: 

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

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

850 

851 if update_records: 

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

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

854 

855 def setValidityEnd( 

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

857 ) -> int: 

858 # docstring is inherited from a base class 

859 if not objects: 

860 return 0 

861 

862 context = self._context 

863 config = context.config 

864 

865 pad_arcsec = 1.0 

866 partitioned_object_ids = self._group_dia_objects_by_partition( 

867 context.partitioner, objects, pad_arcsec 

868 ) 

869 

870 # Check that all objects exist. 

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

872 statements: list[tuple] = [] 

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

874 query = Select(self._keyspace, table_name, ["apdb_part", "diaObjectId"]) 

875 query = query.where(C("apdb_part") == apdb_part) 

876 query = query.where(C("diaObjectId").in_(diaObjectIds)) 

877 statements.append(context.stmt_factory.with_params(query, prepare=False)) 

878 

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

880 records = cast( 

881 list[tuple[int, int]], 

882 select_concurrent( 

883 context.session, 

884 statements, 

885 "read_tuples", 

886 config.connection_config.read_concurrency, 

887 ), 

888 ) 

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

890 

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

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

893 if extra_ids := (found_ids - requested_ids): 

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

895 if raise_on_missing_id: 

896 if missing_ids := (requested_ids - found_ids): 

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

898 

899 # Filter existing records. 

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

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

902 

903 if not objects: 

904 return 0 

905 

906 # Group by partitions again. 

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

908 for apdb_part, diaObjectId in records: 

909 grouped_object_ids[apdb_part].append(diaObjectId) 

910 

911 # Remove all matching rows from DiaObjectLast. 

912 statements = [] 

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

914 delete = ( 

915 Delete(self._keyspace, table_name) 

916 .where(C("apdb_part") == apdb_part) 

917 .where(C("diaObjectId").in_(diaObjectIds)) 

918 ) 

919 statements.append(context.stmt_factory.with_params(delete)) 

920 

921 # Also remove from DiaObjectLastToPartition. 

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

923 delete = Delete(self._keyspace, reverse_table_name).where( 

924 C("diaObjectId").in_([rec[1] for rec in records]) 

925 ) 

926 statements.append(context.stmt_factory.with_params(delete)) 

927 

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

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

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

931 

932 # If repication is enabled then send all updates. 

933 if context.schema.replication_enabled: 

934 current_time = self._current_time() 

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

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

937 

938 update_records = [ 

939 ApdbCloseDiaObjectValidityRecord( 

940 diaObjectId=obj.diaObjectId, 

941 ra=obj.ra, 

942 dec=obj.dec, 

943 update_time_ns=current_time_ns, 

944 update_order=index, 

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

946 nDiaSources=None, 

947 ) 

948 for index, obj in enumerate(objects) 

949 ] 

950 

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

952 

953 return len(objects) 

954 

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

956 # docstring is inherited from a base class 

957 context = self._context 

958 

959 if not context.has_dedup_table: 

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

961 

962 if dedup_time is None: 

963 dedup_time = self._current_time() 

964 

965 validity_start_column = self._timestamp_column_name("validityStart") 

966 

967 # Find latest timestamp in deduplication table. 

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

969 query = Select(self._keyspace, table_name, [ColumnExpr(f'MAX("{validity_start_column}")')]) 

970 stmt = context.stmt_factory(query, prepare=False) 

971 

972 result = context.session.execute(stmt, execution_profile="read_tuples") 

973 max_value = result.one()[0] 

974 if self._schema.has_mjd_timestamps: 

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

976 else: 

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

978 

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

980 if dedup_time >= max_validity_start: 

981 query_str = f'TRUNCATE TABLE "{self._keyspace}"."{table_name}"' 

982 context.session.execute(query_str, execution_profile="write") 

983 else: 

984 dedup_time_value = self._timestamp_column_value(dedup_time) 

985 delete = Delete(self._keyspace, table_name).where(C(validity_start_column) < dedup_time_value) 

986 stmt, params = context.stmt_factory.with_params(delete) 

987 context.session.execute(stmt, params, execution_profile="write") 

988 

989 # Store dedup time. 

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

991 data_json = json.dumps(data) 

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

993 

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

995 # docstring is inherited from a base class 

996 context = self._context 

997 config = context.config 

998 

999 now = self._current_time() 

1000 reassign_time_column = self._timestamp_column_name("ssObjectReassocTime") 

1001 reassignTime = self._timestamp_column_value(now) 

1002 

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

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

1005 # primary keys. 

1006 

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

1008 # split it into 1k IDs per query 

1009 selects: list[tuple] = [] 

1010 columns = ["diaSourceId", "apdb_part", "apdb_time_part", "apdb_replica_chunk"] 

1011 query = Select(self._keyspace, table_name, columns) 

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

1013 full_query = query.where(C("diaSourceId").in_(ids)) 

1014 selects.append(context.stmt_factory.with_params(full_query, prepare=False)) 

1015 

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

1017 result = cast( 

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

1019 select_concurrent( 

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

1021 ), 

1022 ) 

1023 

1024 # Make mapping from source ID to its partition. 

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

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

1027 for row in result: 

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

1029 if row[3] is not None: 

1030 id2chunk_id[row[0]] = row[3] 

1031 

1032 # make sure we know partitions for each ID 

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

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

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

1036 

1037 # Reassign in standard tables 

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

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

1040 apdb_part, apdb_time_part = id2partitions[diaSourceId] 

1041 if config.partitioning.time_partition_tables: 

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

1043 update = ( 

1044 Update(self._keyspace, table_name) 

1045 .values( 

1046 C("ssObjectId").update(ssObjectId), 

1047 C("diaObjectId").update(None), 

1048 C(reassign_time_column).update(reassignTime), 

1049 ) 

1050 .where(C("apdb_part") == apdb_part) 

1051 .where(C("diaSourceId") == diaSourceId) 

1052 ) 

1053 else: 

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

1055 update = ( 

1056 Update(self._keyspace, table_name) 

1057 .values( 

1058 C("ssObjectId").update(ssObjectId), 

1059 C("diaObjectId").update(None), 

1060 C(reassign_time_column).update(reassignTime), 

1061 ) 

1062 .where(C("apdb_part") == apdb_part) 

1063 .where(C("apdb_time_part") == apdb_time_part) 

1064 .where(C("diaSourceId") == diaSourceId) 

1065 ) 

1066 queries.append(context.stmt_factory.with_params(update, prepare=True)) 

1067 

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

1069 if id2chunk_id: 

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

1071 

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

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

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

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

1076 

1077 def withdrawDiaSources( 

1078 self, 

1079 diaSourceIds: Iterable[DiaSourceId], 

1080 *, 

1081 timeWithdrawn: astropy.time.Time | None = None, 

1082 ) -> None: 

1083 # docstring is inherited from a base class 

1084 context = self._context 

1085 config = context.config 

1086 

1087 if timeWithdrawn is None: 

1088 timeWithdrawn = self._current_time() 

1089 time_value = self._timestamp_column_value(timeWithdrawn) 

1090 column_name = self._timestamp_column_name("time_withdrawn") 

1091 

1092 diaSourceIds = list(diaSourceIds) 

1093 source_ids = {source_id.diaSourceId for source_id in diaSourceIds} 

1094 

1095 # Find all DiaSources. 

1096 found_sources = self._get_diasource_data( 

1097 diaSourceIds, "apdb_part", "diaObjectId", "ra", "dec", "midpointMjdTai", column_name 

1098 ) 

1099 

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

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

1102 

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

1104 

1105 update_records: list[ApdbUpdateRecord] = [] 

1106 update_order = 0 

1107 current_time = self._current_time() 

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

1109 

1110 # Update DiaSources. 

1111 statements: list[tuple] = [] 

1112 for source_id in diaSourceIds: 

1113 source_row = found_sources_by_id[source_id.diaSourceId] 

1114 # Ignore sources already withdrawn. 

1115 if getattr(source_row, column_name) is not None: 

1116 continue 

1117 

1118 apdb_part = source_row.apdb_part 

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

1120 

1121 if config.partitioning.time_partition_tables: 

1122 table_name = context.schema.tableName(ApdbTables.DiaSource, time_part) 

1123 update = ( 

1124 Update(self._keyspace, table_name) 

1125 .values(C(column_name).update(time_value)) 

1126 .where(C("apdb_part") == apdb_part) 

1127 .where(C("diaSourceId") == source_id.diaSourceId) 

1128 ) 

1129 else: 

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

1131 update = ( 

1132 Update(self._keyspace, table_name) 

1133 .values(C(column_name).update(time_value)) 

1134 .where(C("apdb_part") == apdb_part) 

1135 .where(C("apdb_time_part") == time_part) 

1136 .where(C("diaSourceId") == source_id.diaSourceId) 

1137 ) 

1138 statements.append(context.stmt_factory.with_params(update, prepare=True)) 

1139 

1140 if context.schema.replication_enabled: 

1141 update_records.append( 

1142 ApdbWithdrawDiaSourceRecord( 

1143 diaSourceId=source_id.diaSourceId, 

1144 ra=source_id.ra, 

1145 dec=source_id.dec, 

1146 midpointMjdTai=source_id.midpointMjdTai, 

1147 update_time_ns=current_time_ns, 

1148 update_order=update_order, 

1149 timeWithdrawnMjdTai=float(timeWithdrawn.tai.mjd), 

1150 ) 

1151 ) 

1152 update_order += 1 

1153 

1154 with self._timer("update_time", tags={"table": "DiaSource", "method": "withdrawDiaSources"}) as timer: 

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

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

1157 

1158 if update_records: 

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

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

1161 

1162 def withdrawDiaForcedSources( 

1163 self, 

1164 diaForcedSourceIds: Iterable[DiaForcedSourceId], 

1165 *, 

1166 timeWithdrawn: astropy.time.Time | None = None, 

1167 ) -> None: 

1168 # docstring is inherited from a base class 

1169 context = self._context 

1170 config = context.config 

1171 

1172 def _fsrc_id(fsource: Any) -> tuple[int, int, int]: 

1173 return (fsource.diaObjectId, fsource.visit, fsource.detector) 

1174 

1175 if timeWithdrawn is None: 

1176 timeWithdrawn = self._current_time() 

1177 time_value = self._timestamp_column_value(timeWithdrawn) 

1178 column_name = self._timestamp_column_name("time_withdrawn") 

1179 

1180 diaForcedSourceIds = list(diaForcedSourceIds) 

1181 fsource_keys = {_fsrc_id(source) for source in diaForcedSourceIds} 

1182 

1183 found_fsources = self._get_diaforcedsource_data( 

1184 diaForcedSourceIds, "apdb_part", "ra", "dec", "midpointMjdTai", column_name 

1185 ) 

1186 

1187 found_keys = {_fsrc_id(row) for row in found_fsources} 

1188 if missing_ids := (fsource_keys - found_keys): 

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

1190 

1191 statements: list[tuple] = [] 

1192 update_records = [] 

1193 update_order = 0 

1194 current_time = self._current_time() 

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

1196 

1197 for source_row in found_fsources: 

1198 # Ignore sources already withdrawn. 

1199 if getattr(source_row, column_name) is not None: 

1200 continue 

1201 

1202 apdb_part = source_row.apdb_part 

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

1204 

1205 if config.partitioning.time_partition_tables: 

1206 table_name = context.schema.tableName(ApdbTables.DiaForcedSource, time_part) 

1207 update = ( 

1208 Update(self._keyspace, table_name) 

1209 .values(C(column_name).update(time_value)) 

1210 .where(C("apdb_part") == apdb_part) 

1211 .where(C("diaObjectId") == source_row.diaObjectId) 

1212 .where(C("visit") == source_row.visit) 

1213 .where(C("detector") == source_row.detector) 

1214 ) 

1215 else: 

1216 table_name = context.schema.tableName(ApdbTables.DiaForcedSource) 

1217 update = ( 

1218 Update(self._keyspace, table_name) 

1219 .values(C(column_name).update(time_value)) 

1220 .where(C("apdb_part") == apdb_part) 

1221 .where(C("apdb_time_part") == time_part) 

1222 .where(C("diaObjectId") == source_row.diaObjectId) 

1223 .where(C("visit") == source_row.visit) 

1224 .where(C("detector") == source_row.detector) 

1225 ) 

1226 statements.append(context.stmt_factory.with_params(update, prepare=True)) 

1227 

1228 if context.schema.replication_enabled: 

1229 update_records.append( 

1230 ApdbWithdrawDiaForcedSourceRecord( 

1231 diaObjectId=source_row.diaObjectId, 

1232 visit=source_row.visit, 

1233 detector=source_row.detector, 

1234 ra=source_row.ra, 

1235 dec=source_row.dec, 

1236 midpointMjdTai=source_row.midpointMjdTai, 

1237 update_time_ns=current_time_ns, 

1238 update_order=update_order, 

1239 timeWithdrawnMjdTai=float(timeWithdrawn.tai.mjd), 

1240 ) 

1241 ) 

1242 update_order += 1 

1243 

1244 if statements: 

1245 with self._timer( 

1246 "update_time", tags={"table": "DiaForcedSource", "method": "withdrawDiaForcedSources"} 

1247 ) as timer: 

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

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

1250 

1251 if update_records: 

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

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

1254 

1255 def countUnassociatedObjects(self) -> int: 

1256 # docstring is inherited from a base class 

1257 

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

1259 raise NotImplementedError() 

1260 

1261 @property 

1262 def schema(self) -> ApdbSchema: 

1263 # docstring is inherited from a base class 

1264 return self._schema 

1265 

1266 @property 

1267 def metadata(self) -> ApdbMetadata: 

1268 # docstring is inherited from a base class 

1269 context = self._context 

1270 return context.metadata 

1271 

1272 @property 

1273 def admin(self) -> ApdbCassandraAdmin: 

1274 # docstring is inherited from a base class 

1275 return ApdbCassandraAdmin(self) 

1276 

1277 def _getSources( 

1278 self, 

1279 region: sphgeom.Region, 

1280 object_ids: Iterable[int] | None, 

1281 mjd_start: float, 

1282 mjd_end: float, 

1283 table_name: ApdbTables, 

1284 ) -> pandas.DataFrame: 

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

1286 

1287 Parameters 

1288 ---------- 

1289 region : `lsst.sphgeom.Region` 

1290 Spherical region. 

1291 object_ids : 

1292 Collection of DiaObject IDs 

1293 mjd_start : `float` 

1294 Lower bound of time interval. 

1295 mjd_end : `float` 

1296 Upper bound of time interval. 

1297 table_name : `ApdbTables` 

1298 Name of the table. 

1299 

1300 Returns 

1301 ------- 

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

1303 Catalog containing DiaSource records. Empty catalog is returned if 

1304 ``object_ids`` is empty. 

1305 """ 

1306 context = self._context 

1307 config = context.config 

1308 

1309 object_id_set: Set[int] = set() 

1310 if object_ids is not None: 

1311 object_id_set = set(object_ids) 

1312 if len(object_id_set) == 0: 

1313 return self._make_empty_catalog(table_name) 

1314 

1315 sp_where, num_sp_part = context.partitioner.spatial_where(region) 

1316 tables, temporal_where = context.partitioner.temporal_where( 

1317 table_name, mjd_start, mjd_end, partitons_range=context.time_partitions_range 

1318 ) 

1319 if not tables: 

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

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

1322 warnings.warn( 

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

1324 ) 

1325 

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

1327 column_names = context.schema.apdbColumnNames(table_name) 

1328 

1329 # Build all queries 

1330 statements: list[tuple] = [] 

1331 for table in tables: 

1332 query = Select(self._keyspace, table, column_names) 

1333 for clause in QExpr.combine(sp_where, temporal_where): 

1334 statements.append(context.stmt_factory.with_params(query.where(clause), prepare=True)) 

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

1336 

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

1338 table_data_raw = cast( 

1339 ApdbCassandraTableData, 

1340 select_concurrent( 

1341 context.session, 

1342 statements, 

1343 "read_raw_multi", 

1344 config.connection_config.read_concurrency, 

1345 ), 

1346 ) 

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

1348 timer.add_values( 

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

1350 ) 

1351 

1352 # filter by given object IDs 

1353 if len(object_id_set) > 0: 

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

1355 

1356 # precise filtering on midpointMjdTai 

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

1358 

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

1360 

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

1362 return catalog 

1363 

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

1365 context = self._context 

1366 config = context.config 

1367 

1368 # Cassandra timestamp uses milliseconds since epoch 

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

1370 

1371 # everything goes into a single partition 

1372 partition = 0 

1373 

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

1375 

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

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

1378 if context.has_chunk_sub_partitions: 

1379 columns.append("has_subchunks") 

1380 values.append(True) 

1381 

1382 query = Insert(self._keyspace, table_name, columns) 

1383 stmt = context.stmt_factory(query) 

1384 

1385 context.session.execute( 

1386 stmt, 

1387 values, 

1388 timeout=config.connection_config.write_timeout, 

1389 execution_profile="write", 

1390 ) 

1391 

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

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

1394 context = self._context 

1395 config = context.config 

1396 

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

1398 queries = [] 

1399 object_count = 0 

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

1401 id_chunk_list = tuple(id_chunk) 

1402 query = Select(self._keyspace, table_name, ("diaObjectId", "apdb_part")) 

1403 query = query.where(C("diaObjectId").in_(id_chunk_list)) 

1404 queries.append(context.stmt_factory.with_params(query, prepare=False)) 

1405 object_count += len(id_chunk_list) 

1406 

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

1408 data = cast( 

1409 ApdbTableData, 

1410 select_concurrent( 

1411 context.session, 

1412 queries, 

1413 "read_raw_multi", 

1414 config.connection_config.read_concurrency, 

1415 ), 

1416 ) 

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

1418 

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

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

1421 

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

1423 

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

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

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

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

1428 ones. 

1429 """ 

1430 context = self._context 

1431 

1432 # Extract all object IDs. 

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

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

1435 

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

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

1438 new_part = new_partitions.get(oid, old_part) 

1439 if new_part != old_part: 

1440 moved_oids[oid] = (old_part, new_part) 

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

1442 

1443 if moved_oids: 

1444 # Delete old records from DiaObjectLast. 

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

1446 query = Delete(self._keyspace, table_name) 

1447 query = query.where('apdb_part = {} AND "diaObjectId" = {}', (-1, -1)) 

1448 statement = context.stmt_factory(query, prepare=True) 

1449 queries = [] 

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

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

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

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

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

1455 

1456 # Add all new records to the map. 

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

1458 insert = Insert(self._keyspace, table_name, ("diaObjectId", "apdb_part")) 

1459 statement = context.stmt_factory(insert, prepare=True) 

1460 

1461 queries = [] 

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

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

1464 

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

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

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

1468 

1469 def _storeDiaObjects( 

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

1471 ) -> None: 

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

1473 

1474 Parameters 

1475 ---------- 

1476 objs : `pandas.DataFrame` 

1477 Catalog with DiaObject records 

1478 visit_time : `astropy.time.Time` 

1479 Time of the current visit. 

1480 replica_chunk : `ReplicaChunk` or `None` 

1481 Replica chunk identifier if replication is configured. 

1482 """ 

1483 if len(objs) == 0: 

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

1485 return 

1486 

1487 context = self._context 

1488 config = context.config 

1489 

1490 self._deleteMovingObjects(objs) 

1491 

1492 validity_start_column = self._timestamp_column_name("validityStart") 

1493 timestamp = self._timestamp_column_value(visit_time) 

1494 

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

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

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

1498 extra_columns[validity_start_column] = timestamp 

1499 

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

1501 

1502 extra_columns[validity_start_column] = timestamp 

1503 visit_time_part = context.partitioner.time_partition(visit_time) 

1504 time_part: int | None = visit_time_part 

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

1506 self._check_time_partitions([visit_time_part], time_partitions_range) 

1507 if not config.partitioning.time_partition_tables: 

1508 extra_columns["apdb_time_part"] = time_part 

1509 time_part = None 

1510 

1511 # Only store DiaObects if not doing replication or explicitly 

1512 # configured to always store them. 

1513 if replica_chunk is None or not config.replica_skips_diaobjects: 

1514 self._storeObjectsPandas( 

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

1516 ) 

1517 

1518 if replica_chunk is not None: 

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

1520 table = ExtraTables.DiaObjectChunks 

1521 if context.has_chunk_sub_partitions: 

1522 table = ExtraTables.DiaObjectChunks2 

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

1524 # that different clients could wrtite to different partitions. 

1525 # This makes it not exactly reproducible. 

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

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

1528 

1529 # Store copy of the records in dedup table. 

1530 if context.has_dedup_table: 

1531 table = ExtraTables.DiaObjectDedup 

1532 extra_columns = { 

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

1534 validity_start_column: timestamp, 

1535 } 

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

1537 

1538 def _storeDiaSources( 

1539 self, 

1540 table_name: ApdbTables, 

1541 sources: pandas.DataFrame, 

1542 replica_chunk: ReplicaChunk | None, 

1543 ) -> int | None: 

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

1545 

1546 Parameters 

1547 ---------- 

1548 table_name : `ApdbTables` 

1549 Table where to store the data. 

1550 sources : `pandas.DataFrame` 

1551 Catalog containing DiaSource records 

1552 visit_time : `astropy.time.Time` 

1553 Time of the current visit. 

1554 replica_chunk : `ReplicaChunk` or `None` 

1555 Replica chunk identifier if replication is configured. 

1556 

1557 Returns 

1558 ------- 

1559 subchunk : `int` or `None` 

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

1561 not enabled ot subchunking is not enabled. 

1562 """ 

1563 context = self._context 

1564 config = context.config 

1565 

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

1567 # as visit_time is not really a visit time. 

1568 tp_sources = sources.copy(deep=False) 

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

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

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

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

1573 if not config.partitioning.time_partition_tables: 

1574 self._storeObjectsPandas(tp_sources, table_name) 

1575 else: 

1576 # Group by time partition 

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

1578 if len(partitions) == 1: 

1579 # Single partition - just save the whole thing. 

1580 time_part = partitions.pop() 

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

1582 else: 

1583 # group by time partition. 

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

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

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

1587 

1588 subchunk: int | None = None 

1589 if replica_chunk is not None: 

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

1591 if context.has_chunk_sub_partitions: 

1592 subchunk = random.randrange(config.replica_sub_chunk_count) 

1593 extra_columns["apdb_replica_subchunk"] = subchunk 

1594 if table_name is ApdbTables.DiaSource: 

1595 extra_table = ExtraTables.DiaSourceChunks2 

1596 else: 

1597 extra_table = ExtraTables.DiaForcedSourceChunks2 

1598 else: 

1599 if table_name is ApdbTables.DiaSource: 

1600 extra_table = ExtraTables.DiaSourceChunks 

1601 else: 

1602 extra_table = ExtraTables.DiaForcedSourceChunks 

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

1604 

1605 return subchunk 

1606 

1607 def _check_time_partitions( 

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

1609 ) -> None: 

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

1611 

1612 Parameters 

1613 ---------- 

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

1615 Time partitions for new data. 

1616 time_partitions_range : `ApdbCassandraTimePartitionRange` 

1617 Currrent time partition range. 

1618 """ 

1619 partitions = set(partitions) 

1620 min_part = min(partitions) 

1621 max_part = max(partitions) 

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

1623 raise ValueError( 

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

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

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

1627 ) 

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

1629 if max_part == time_partitions_range.end: 

1630 warnings.warn( 

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

1632 stacklevel=3, 

1633 ) 

1634 

1635 def _storeDiaSourcesPartitions( 

1636 self, 

1637 sources: pandas.DataFrame, 

1638 visit_time: astropy.time.Time, 

1639 replica_chunk: ReplicaChunk | None, 

1640 subchunk: int | None, 

1641 ) -> None: 

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

1643 

1644 Parameters 

1645 ---------- 

1646 sources : `pandas.DataFrame` 

1647 Catalog containing DiaSource records 

1648 visit_time : `astropy.time.Time` 

1649 Time of the current visit. 

1650 replica_chunk : `ReplicaChunk` or `None` 

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

1652 subchunk : `int` or `None` 

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

1654 sub-chunking is not used. 

1655 """ 

1656 context = self._context 

1657 

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

1659 extra_columns = { 

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

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

1662 } 

1663 if context.has_chunk_sub_partitions: 

1664 extra_columns["apdb_replica_subchunk"] = subchunk 

1665 

1666 self._storeObjectsPandas( 

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

1668 ) 

1669 

1670 def _storeObjectsPandas( 

1671 self, 

1672 records: pandas.DataFrame, 

1673 table_name: ApdbTables | ExtraTables, 

1674 extra_columns: Mapping | None = None, 

1675 time_part: int | None = None, 

1676 ) -> None: 

1677 """Store generic objects. 

1678 

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

1680 

1681 Parameters 

1682 ---------- 

1683 records : `pandas.DataFrame` 

1684 Catalog containing object records 

1685 table_name : `ApdbTables` 

1686 Name of the table as defined in APDB schema. 

1687 extra_columns : `dict`, optional 

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

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

1690 columns exist there. 

1691 time_part : `int`, optional 

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

1693 

1694 Notes 

1695 ----- 

1696 If Pandas catalog contains additional columns not defined in table 

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

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

1699 in a catalog or ``extra_columns``. 

1700 """ 

1701 context = self._context 

1702 

1703 # use extra columns if specified 

1704 if extra_columns is None: 

1705 extra_columns = {} 

1706 extra_fields = list(extra_columns.keys()) 

1707 

1708 # Fields that will come from dataframe. 

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

1710 

1711 column_map = context.schema.getColumnMap(table_name) 

1712 # list of columns (as in felis schema) 

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

1714 fields += extra_fields 

1715 

1716 # check that all partitioning and clustering columns are defined 

1717 partition_columns = context.schema.partitionColumns(table_name) 

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

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

1720 if missing_columns: 

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

1722 

1723 batch_size = self._batch_size(table_name) 

1724 

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

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

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

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

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

1730 values = [] 

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

1732 for field in df_fields: 

1733 if field not in column_map: 

1734 continue 

1735 value = getattr(rec, field) 

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

1737 if isinstance(value, pandas.Timestamp): 

1738 value = value.to_pydatetime() 

1739 elif value is pandas.NaT: 

1740 value = None 

1741 else: 

1742 # Assume it's seconds since epoch, Cassandra 

1743 # datetime is in milliseconds 

1744 value = int(value * 1000) 

1745 value = literal(value) 

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

1747 if field in partition_columns: 

1748 partitioning_values[field] = value 

1749 for field in extra_fields: 

1750 value = literal(extra_columns[field]) 

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

1752 if field in partition_columns: 

1753 partitioning_values[field] = value 

1754 

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

1756 values_by_key[key].append(values) 

1757 

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

1759 

1760 query = Insert(self._keyspace, table, fields) 

1761 statement = context.stmt_factory(query, prepare=True) 

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

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

1764 queries = [] 

1765 for key_values in values_by_key.values(): 

1766 for values_chunk in chunk_iterable(key_values, batch_size): 

1767 batch = cassandra.query.BatchStatement() 

1768 for row_values in values_chunk: 

1769 batch.add(statement, row_values) 

1770 queries.append((batch, None)) 

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

1772 

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

1774 with self._timer( 

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

1776 ) as timer: 

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

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

1779 

1780 def _storeUpdateRecords( 

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

1782 ) -> None: 

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

1784 

1785 Parameters 

1786 ---------- 

1787 records : `list` [`ApdbUpdateRecord`] 

1788 Records to store. 

1789 chunk : `ReplicaChunk` 

1790 Replica chunk for these records. 

1791 store_chunk : `bool` 

1792 If True then also store replica chunk. 

1793 

1794 Raises 

1795 ------ 

1796 TypeError 

1797 Raised if replication is not enabled for this instance. 

1798 """ 

1799 context = self._context 

1800 config = context.config 

1801 

1802 if not context.schema.replication_enabled: 

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

1804 

1805 if store_chunk: 

1806 self._storeReplicaChunk(chunk) 

1807 

1808 apdb_replica_chunk = chunk.id 

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

1810 # multiple calls to this method. 

1811 update_unique_id = uuid.uuid4() 

1812 

1813 rows = [] 

1814 for record in records: 

1815 rows.append( 

1816 [ 

1817 apdb_replica_chunk, 

1818 record.update_time_ns, 

1819 record.update_order, 

1820 update_unique_id, 

1821 record.to_json(), 

1822 ] 

1823 ) 

1824 columns = [ 

1825 "apdb_replica_chunk", 

1826 "update_time_ns", 

1827 "update_order", 

1828 "update_unique_id", 

1829 "update_payload", 

1830 ] 

1831 if context.has_chunk_sub_partitions: 

1832 subchunk = random.randrange(config.replica_sub_chunk_count) 

1833 for row in rows: 

1834 row.append(subchunk) 

1835 columns.append("apdb_replica_subchunk") 

1836 

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

1838 query = Insert(self._keyspace, table_name, columns) 

1839 stmt = context.stmt_factory(query) 

1840 queries = [(stmt, row) for row in rows] 

1841 

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

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

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

1845 

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

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

1848 DataFrame. 

1849 

1850 Parameters 

1851 ---------- 

1852 df : `pandas.DataFrame` 

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

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

1855 

1856 Returns 

1857 ------- 

1858 df : `pandas.DataFrame` 

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

1860 for ra/dec coordinates. 

1861 

1862 Notes 

1863 ----- 

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

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

1866 is returned. 

1867 """ 

1868 context = self._context 

1869 config = context.config 

1870 

1871 # Calculate pixelization index for every record. 

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

1873 ra_col, dec_col = config.ra_dec_columns 

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

1875 idx = context.partitioner.pixel(ra, dec) 

1876 apdb_part[i] = idx 

1877 df = df.copy() 

1878 df["apdb_part"] = apdb_part 

1879 return df 

1880 

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

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

1883 

1884 Parameters 

1885 ---------- 

1886 table_name : `ApdbTables` 

1887 Name of the table. 

1888 

1889 Returns 

1890 ------- 

1891 catalog : `pandas.DataFrame` 

1892 An empty catalog. 

1893 """ 

1894 table = self.schema.tableSchemas[table_name] 

1895 

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

1897 return pandas.DataFrame(data) 

1898 

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

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

1901 type. 

1902 

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

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

1905 drop timezone. 

1906 """ 

1907 # Find all columns with aware timestamps. 

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

1909 for column in columns: 

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

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

1912 return df 

1913 

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

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

1916 context = self._context 

1917 config = context.config 

1918 

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

1920 batch_size = 65_535 

1921 if 0 < config.batch_statement_limit < batch_size: 

1922 batch_size = config.batch_statement_limit 

1923 if config.batch_size_limit > 0: 

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

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

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

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

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

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

1930 # ignore. 

1931 row_size = context.schema.table_row_size(table) 

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

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

1934 return batch_size 

1935 

1936 def _group_dia_objects_by_partition( 

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

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

1939 """Group DiaObjects by partition. 

1940 

1941 Parameters 

1942 ---------- 

1943 partitioner : `Partitioner` 

1944 Objects which knows how to partition things. 

1945 objects : `list` [`DiaObjectId`] 

1946 Collection of objects to partition. 

1947 pad_arcsec : `float` 

1948 Additional padding around object position. 

1949 

1950 Returns 

1951 ------- 

1952 grouped_objects 

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

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

1955 """ 

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

1957 for obj_id in objects: 

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

1959 for pixel in partitions: 

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

1961 return partitioned_object_ids 

1962 

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

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

1965 return self._schema.timestamp_column_name(column) 

1966 

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

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

1969 if self._schema.has_mjd_timestamps: 

1970 return float(time.tai.mjd) 

1971 else: 

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

1973 

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

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

1976 records as a list of named tuples. 

1977 """ 

1978 context = self._context 

1979 config = context.config 

1980 partitioner = context.partitioner 

1981 

1982 columns = ("diaSourceId",) + columns 

1983 

1984 # Allow some uncertainty for coordinates and time when calculating 

1985 # partitions. 

1986 statements: list[tuple] = [] 

1987 pad_arcsec = 1.0 

1988 pad_time_day = 10 / (24 * 3600) 

1989 for source_id in source_ids: 

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

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

1992 spatial_where, _ = partitioner.spatial_where(region) 

1993 

1994 tables, temporal_where = partitioner.temporal_where( 

1995 ApdbTables.DiaSource, 

1996 source_id.midpointMjdTai - pad_time_day, 

1997 source_id.midpointMjdTai + pad_time_day, 

1998 partitons_range=context.time_partitions_range, 

1999 query_per_time_part=True, 

2000 ) 

2001 

2002 id_where = QExpr('"diaSourceId" = {}', (source_id.diaSourceId,)) 

2003 

2004 for table in tables: 

2005 query = Select(self._keyspace, table, columns) 

2006 for clause in QExpr.combine(spatial_where, temporal_where, extra=id_where): 

2007 statements.append(context.stmt_factory.with_params(query.where(clause), prepare=True)) 

2008 

2009 with self._timer( 

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

2011 ) as timer: 

2012 result = cast( 

2013 list[tuple], 

2014 select_concurrent( 

2015 context.session, 

2016 statements, 

2017 "read_named_tuples", 

2018 config.connection_config.read_concurrency, 

2019 ), 

2020 ) 

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

2022 

2023 return result 

2024 

2025 def _get_diaforcedsource_data(self, source_ids: Iterable[DiaForcedSourceId], *columns: str) -> list: 

2026 """Select records from DiaForcedSource table by (diaObjectId, visit, 

2027 detector) and return all records as a list of named tuples. 

2028 """ 

2029 context = self._context 

2030 config = context.config 

2031 partitioner = context.partitioner 

2032 

2033 columns = ("diaObjectId", "visit", "detector") + columns 

2034 

2035 # Allow some uncertainty for coordinates and time when calculating 

2036 # partitions. 

2037 statements: list[tuple] = [] 

2038 pad_arcsec = 1.0 

2039 pad_time_day = 10 / (24 * 3600) 

2040 for source_id in source_ids: 

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

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

2043 spatial_where, _ = partitioner.spatial_where(region) 

2044 

2045 tables, temporal_where = partitioner.temporal_where( 

2046 ApdbTables.DiaForcedSource, 

2047 source_id.midpointMjdTai - pad_time_day, 

2048 source_id.midpointMjdTai + pad_time_day, 

2049 partitons_range=context.time_partitions_range, 

2050 query_per_time_part=True, 

2051 ) 

2052 

2053 id_where = ( 

2054 (C("diaObjectId") == source_id.diaObjectId) 

2055 & (C("visit") == source_id.visit) 

2056 & (C("detector") == source_id.detector) 

2057 ) 

2058 

2059 for table in tables: 

2060 query = Select(self._keyspace, table, columns) 

2061 for clause in QExpr.combine(spatial_where, temporal_where, extra=id_where): 

2062 statements.append(context.stmt_factory.with_params(query.where(clause), prepare=True)) 

2063 

2064 with self._timer( 

2065 "select_time", tags={"table": "DiaForcedSource", "method": "_get_diaforcedsource_data"} 

2066 ) as timer: 

2067 result = cast( 

2068 list[tuple], 

2069 select_concurrent( 

2070 context.session, 

2071 statements, 

2072 "read_named_tuples", 

2073 config.connection_config.read_concurrency, 

2074 ), 

2075 ) 

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

2077 

2078 return result 

2079 

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

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

2082 all records as a list of named tuples. 

2083 """ 

2084 context = self._context 

2085 config = context.config 

2086 partitioner = context.partitioner 

2087 

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

2089 columns = ("diaObjectId",) + columns 

2090 

2091 # Allow some uncertainty for coordinates when calculating partitions. 

2092 pad_arcsec = 1.0 

2093 ids_by_partition = defaultdict(list) 

2094 for object_id in object_ids: 

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

2096 for pixel in pixels: 

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

2098 

2099 statements: list[tuple] = [] 

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

2101 query = Select(self._keyspace, table_name, columns) 

2102 query = query.where(C("apdb_part") == apdb_part) 

2103 query = query.where(C("diaObjectId").in_(diaObjectIds)) 

2104 statements.append(context.stmt_factory.with_params(query, prepare=False)) 

2105 

2106 with self._timer( 

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

2108 ) as timer: 

2109 result = cast( 

2110 list[tuple], 

2111 select_concurrent( 

2112 context.session, 

2113 statements, 

2114 "read_named_tuples", 

2115 config.connection_config.read_concurrency, 

2116 ), 

2117 ) 

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

2119 

2120 return result 

2121 

2122 def _fix_null_columns(self, table: ApdbTables, df: pandas.DataFrame) -> None: 

2123 """For non-null columns replace NULL values with column defaults. 

2124 

2125 When new non-null columns are added to the schema we do not populate 

2126 values for existing records. Instead when reading old data back we 

2127 fill NULLs with the defaults. The dataflame is updated in-place. 

2128 """ 

2129 # Find all non-null columns which also have default values defined. 

2130 table_schema = self._schema.tableSchemas[table] 

2131 defaults = { 

2132 column.name: column.value 

2133 for column in table_schema.columns 

2134 if not column.nullable and column.value is not None 

2135 } 

2136 if defaults: 

2137 df.fillna(defaults, inplace=True)