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

924 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-07 09:42 +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 _LOG.debug("found %s DiaObjectDedup records", objects.shape[0]) 

562 return objects 

563 

564 def getDiaSourcesForDiaObjects( 

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

566 ) -> pandas.DataFrame: 

567 # docstring is inherited from a base class 

568 context = self._context 

569 config = context.config 

570 

571 # Which tables to query and temporal constraints. 

572 end_time = self._current_time() 

573 tables, temporal_where = context.partitioner.temporal_where( 

574 ApdbTables.DiaSource, 

575 start_time, 

576 end_time, 

577 partitons_range=context.time_partitions_range, 

578 query_per_time_part=False, 

579 ) 

580 if not tables: 

581 warnings.warn( 

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

583 "time partitions." 

584 ) 

585 

586 # Group DiaObjects by partition. 

587 partitioned_object_ids = self._group_dia_objects_by_partition( 

588 context.partitioner, objects, max_dist_arcsec 

589 ) 

590 

591 # Columns to return. 

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

593 

594 # Make a bunch of queries. 

595 statements = [] 

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

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

598 for table in tables: 

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

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

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

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

603 statements.append( 

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

605 ) 

606 

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

608 

609 with self._timer( 

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

611 ) as timer: 

612 table_data_raw = cast( 

613 ApdbCassandraTableData, 

614 select_concurrent( 

615 context.session, 

616 statements, 

617 "read_raw_multi", 

618 config.connection_config.read_concurrency, 

619 ), 

620 ) 

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

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

623 

624 # precise filtering on midpointMjdTai 

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

626 

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

628 

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

630 return catalog 

631 

632 def containsVisitDetector( 

633 self, 

634 visit: int, 

635 detector: int, 

636 region: sphgeom.Region | None = None, 

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

638 ) -> bool: 

639 # docstring is inherited from a base class 

640 context = self._context 

641 

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

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

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

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

646 

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

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

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

650 

651 def store( 

652 self, 

653 visit_time: astropy.time.Time, 

654 objects: pandas.DataFrame, 

655 sources: pandas.DataFrame | None = None, 

656 forced_sources: pandas.DataFrame | None = None, 

657 ) -> None: 

658 # docstring is inherited from a base class 

659 context = self._context 

660 config = context.config 

661 

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

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

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

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

666 for df in sources, forced_sources: 

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

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

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

670 visit_detector.add((visit, detector)) 

671 

672 if visit_detector: 

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

674 # concurrency. 

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

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

677 stmt = context.stmt_factory(query) 

678 for item in visit_detector: 

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

680 

681 objects = self._fix_input_timestamps(objects) 

682 if sources is not None: 

683 sources = self._fix_input_timestamps(sources) 

684 if forced_sources is not None: 

685 forced_sources = self._fix_input_timestamps(forced_sources) 

686 

687 replica_chunk: ReplicaChunk | None = None 

688 if context.schema.replication_enabled: 

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

690 self._storeReplicaChunk(replica_chunk) 

691 

692 # fill region partition column for DiaObjects 

693 objects = self._add_apdb_part(objects) 

694 self._storeDiaObjects(objects, visit_time, replica_chunk) 

695 

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

697 # copy apdb_part column from DiaObjects to DiaSources 

698 sources = self._add_apdb_part(sources) 

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

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

701 

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

703 forced_sources = self._add_apdb_part(forced_sources) 

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

705 

706 def reassignDiaSourcesToDiaObjects( 

707 self, 

708 idMap: Mapping[DiaSourceId, int], 

709 *, 

710 increment_nDiaSources: bool = True, 

711 decrement_nDiaSources: bool = True, 

712 ) -> None: 

713 # docstring is inherited from a base class 

714 context = self._context 

715 config = context.config 

716 

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

718 

719 # Find all DiaSources. 

720 found_sources = self._get_diasource_data( 

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

722 ) 

723 

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

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

726 

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

728 

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

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

731 # send updated values to replica. 

732 current_object_ids = { 

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

734 } 

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

736 new_object_ids = { 

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

738 for source_id, diaObjectId in idMap.items() 

739 } 

740 all_object_ids = new_object_ids | current_object_ids 

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

742 

743 if missing_ids := ( 

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

745 ): 

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

747 

748 update_records: list[ApdbUpdateRecord] = [] 

749 update_order = 0 

750 current_time = self._current_time() 

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

752 

753 # Update DiaSources. 

754 statements: list[tuple] = [] 

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

756 source_row = found_sources_by_id[source_id.diaSourceId] 

757 apdb_part = source_row.apdb_part 

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

759 

760 if config.partitioning.time_partition_tables: 

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

762 update = ( 

763 Update(self._keyspace, table_name) 

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

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

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

767 ) 

768 else: 

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

770 update = ( 

771 Update(self._keyspace, table_name) 

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

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

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

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

776 ) 

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

778 

779 if context.schema.replication_enabled: 

780 update_records.append( 

781 ApdbReassignDiaSourceToDiaObjectRecord( 

782 diaSourceId=source_id.diaSourceId, 

783 ra=source_id.ra, 

784 dec=source_id.dec, 

785 midpointMjdTai=source_id.midpointMjdTai, 

786 diaObjectId=diaObjectId, 

787 update_time_ns=current_time_ns, 

788 update_order=update_order, 

789 ) 

790 ) 

791 update_order += 1 

792 

793 with self._timer( 

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

795 ) as timer: 

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

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

798 

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

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

801 # update records. 

802 if increment_nDiaSources or decrement_nDiaSources: 

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

804 update = ( 

805 Update(self._keyspace, table_name) 

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

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

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

809 ) 

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

811 statements = [] 

812 

813 # Calculate increments/decrements for all affected DiaObjects. 

814 increments: Counter = Counter() 

815 if increment_nDiaSources: 

816 increments.update(idMap.values()) 

817 if decrement_nDiaSources: 

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

819 

820 for row in found_objects: 

821 if increments.get(row.diaObjectId): 

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

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

824 

825 # Also send updated values to replica. 

826 if context.schema.replication_enabled: 

827 update_records.append( 

828 ApdbUpdateNDiaSourcesRecord( 

829 diaObjectId=row.diaObjectId, 

830 ra=row.ra, 

831 dec=row.dec, 

832 nDiaSources=nDiaSources, 

833 update_time_ns=current_time_ns, 

834 update_order=update_order, 

835 ) 

836 ) 

837 update_order += 1 

838 

839 if statements: 

840 with self._timer( 

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

842 ) as timer: 

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

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

845 

846 if update_records: 

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

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

849 

850 def setValidityEnd( 

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

852 ) -> int: 

853 # docstring is inherited from a base class 

854 if not objects: 

855 return 0 

856 

857 context = self._context 

858 config = context.config 

859 

860 pad_arcsec = 1.0 

861 partitioned_object_ids = self._group_dia_objects_by_partition( 

862 context.partitioner, objects, pad_arcsec 

863 ) 

864 

865 # Check that all objects exist. 

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

867 statements: list[tuple] = [] 

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

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

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

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

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

873 

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

875 records = cast( 

876 list[tuple[int, int]], 

877 select_concurrent( 

878 context.session, 

879 statements, 

880 "read_tuples", 

881 config.connection_config.read_concurrency, 

882 ), 

883 ) 

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

885 

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

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

888 if extra_ids := (found_ids - requested_ids): 

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

890 if raise_on_missing_id: 

891 if missing_ids := (requested_ids - found_ids): 

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

893 

894 # Filter existing records. 

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

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

897 

898 if not objects: 

899 return 0 

900 

901 # Group by partitions again. 

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

903 for apdb_part, diaObjectId in records: 

904 grouped_object_ids[apdb_part].append(diaObjectId) 

905 

906 # Remove all matching rows from DiaObjectLast. 

907 statements = [] 

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

909 delete = ( 

910 Delete(self._keyspace, table_name) 

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

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

913 ) 

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

915 

916 # Also remove from DiaObjectLastToPartition. 

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

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

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

920 ) 

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

922 

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

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

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

926 

927 # If repication is enabled then send all updates. 

928 if context.schema.replication_enabled: 

929 current_time = self._current_time() 

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

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

932 

933 update_records = [ 

934 ApdbCloseDiaObjectValidityRecord( 

935 diaObjectId=obj.diaObjectId, 

936 ra=obj.ra, 

937 dec=obj.dec, 

938 update_time_ns=current_time_ns, 

939 update_order=index, 

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

941 nDiaSources=None, 

942 ) 

943 for index, obj in enumerate(objects) 

944 ] 

945 

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

947 

948 return len(objects) 

949 

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

951 # docstring is inherited from a base class 

952 context = self._context 

953 

954 if not context.has_dedup_table: 

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

956 

957 if dedup_time is None: 

958 dedup_time = self._current_time() 

959 

960 validity_start_column = self._timestamp_column_name("validityStart") 

961 

962 # Find latest timestamp in deduplication table. 

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

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

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

966 

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

968 max_value = result.one()[0] 

969 if self._schema.has_mjd_timestamps: 

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

971 else: 

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

973 

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

975 if dedup_time >= max_validity_start: 

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

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

978 else: 

979 dedup_time_value = self._timestamp_column_value(dedup_time) 

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

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

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

983 

984 # Store dedup time. 

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

986 data_json = json.dumps(data) 

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

988 

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

990 # docstring is inherited from a base class 

991 context = self._context 

992 config = context.config 

993 

994 now = self._current_time() 

995 reassign_time_column = self._timestamp_column_name("ssObjectReassocTime") 

996 reassignTime = self._timestamp_column_value(now) 

997 

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

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

1000 # primary keys. 

1001 

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

1003 # split it into 1k IDs per query 

1004 selects: list[tuple] = [] 

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

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

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

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

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

1010 

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

1012 result = cast( 

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

1014 select_concurrent( 

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

1016 ), 

1017 ) 

1018 

1019 # Make mapping from source ID to its partition. 

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

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

1022 for row in result: 

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

1024 if row[3] is not None: 

1025 id2chunk_id[row[0]] = row[3] 

1026 

1027 # make sure we know partitions for each ID 

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

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

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

1031 

1032 # Reassign in standard tables 

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

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

1035 apdb_part, apdb_time_part = id2partitions[diaSourceId] 

1036 if config.partitioning.time_partition_tables: 

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

1038 update = ( 

1039 Update(self._keyspace, table_name) 

1040 .values( 

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

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

1043 C(reassign_time_column).update(reassignTime), 

1044 ) 

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

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

1047 ) 

1048 else: 

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

1050 update = ( 

1051 Update(self._keyspace, table_name) 

1052 .values( 

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

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

1055 C(reassign_time_column).update(reassignTime), 

1056 ) 

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

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

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

1060 ) 

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

1062 

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

1064 if id2chunk_id: 

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

1066 

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

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

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

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

1071 

1072 def withdrawDiaSources( 

1073 self, 

1074 diaSourceIds: Iterable[DiaSourceId], 

1075 *, 

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

1077 ) -> None: 

1078 # docstring is inherited from a base class 

1079 context = self._context 

1080 config = context.config 

1081 

1082 if timeWithdrawn is None: 

1083 timeWithdrawn = self._current_time() 

1084 time_value = self._timestamp_column_value(timeWithdrawn) 

1085 column_name = self._timestamp_column_name("time_withdrawn") 

1086 

1087 diaSourceIds = list(diaSourceIds) 

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

1089 

1090 # Find all DiaSources. 

1091 found_sources = self._get_diasource_data( 

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

1093 ) 

1094 

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

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

1097 

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

1099 

1100 update_records: list[ApdbUpdateRecord] = [] 

1101 update_order = 0 

1102 current_time = self._current_time() 

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

1104 

1105 # Update DiaSources. 

1106 statements: list[tuple] = [] 

1107 for source_id in diaSourceIds: 

1108 source_row = found_sources_by_id[source_id.diaSourceId] 

1109 # Ignore sources already withdrawn. 

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

1111 continue 

1112 

1113 apdb_part = source_row.apdb_part 

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

1115 

1116 if config.partitioning.time_partition_tables: 

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

1118 update = ( 

1119 Update(self._keyspace, table_name) 

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

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

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

1123 ) 

1124 else: 

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

1126 update = ( 

1127 Update(self._keyspace, table_name) 

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

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

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

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

1132 ) 

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

1134 

1135 if context.schema.replication_enabled: 

1136 update_records.append( 

1137 ApdbWithdrawDiaSourceRecord( 

1138 diaSourceId=source_id.diaSourceId, 

1139 ra=source_id.ra, 

1140 dec=source_id.dec, 

1141 midpointMjdTai=source_id.midpointMjdTai, 

1142 update_time_ns=current_time_ns, 

1143 update_order=update_order, 

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

1145 ) 

1146 ) 

1147 update_order += 1 

1148 

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

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

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

1152 

1153 if update_records: 

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

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

1156 

1157 def withdrawDiaForcedSources( 

1158 self, 

1159 diaForcedSourceIds: Iterable[DiaForcedSourceId], 

1160 *, 

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

1162 ) -> None: 

1163 # docstring is inherited from a base class 

1164 context = self._context 

1165 config = context.config 

1166 

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

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

1169 

1170 if timeWithdrawn is None: 

1171 timeWithdrawn = self._current_time() 

1172 time_value = self._timestamp_column_value(timeWithdrawn) 

1173 column_name = self._timestamp_column_name("time_withdrawn") 

1174 

1175 diaForcedSourceIds = list(diaForcedSourceIds) 

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

1177 

1178 found_fsources = self._get_diaforcedsource_data( 

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

1180 ) 

1181 

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

1183 if missing_ids := (fsource_keys - found_keys): 

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

1185 

1186 statements: list[tuple] = [] 

1187 update_records = [] 

1188 update_order = 0 

1189 current_time = self._current_time() 

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

1191 

1192 for source_row in found_fsources: 

1193 # Ignore sources already withdrawn. 

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

1195 continue 

1196 

1197 apdb_part = source_row.apdb_part 

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

1199 

1200 if config.partitioning.time_partition_tables: 

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

1202 update = ( 

1203 Update(self._keyspace, table_name) 

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

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

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

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

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

1209 ) 

1210 else: 

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

1212 update = ( 

1213 Update(self._keyspace, table_name) 

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

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

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

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

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

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

1220 ) 

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

1222 

1223 if context.schema.replication_enabled: 

1224 update_records.append( 

1225 ApdbWithdrawDiaForcedSourceRecord( 

1226 diaObjectId=source_row.diaObjectId, 

1227 visit=source_row.visit, 

1228 detector=source_row.detector, 

1229 ra=source_row.ra, 

1230 dec=source_row.dec, 

1231 midpointMjdTai=source_row.midpointMjdTai, 

1232 update_time_ns=current_time_ns, 

1233 update_order=update_order, 

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

1235 ) 

1236 ) 

1237 update_order += 1 

1238 

1239 if statements: 

1240 with self._timer( 

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

1242 ) as timer: 

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

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

1245 

1246 if update_records: 

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

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

1249 

1250 def countUnassociatedObjects(self) -> int: 

1251 # docstring is inherited from a base class 

1252 

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

1254 raise NotImplementedError() 

1255 

1256 @property 

1257 def schema(self) -> ApdbSchema: 

1258 # docstring is inherited from a base class 

1259 return self._schema 

1260 

1261 @property 

1262 def metadata(self) -> ApdbMetadata: 

1263 # docstring is inherited from a base class 

1264 context = self._context 

1265 return context.metadata 

1266 

1267 @property 

1268 def admin(self) -> ApdbCassandraAdmin: 

1269 # docstring is inherited from a base class 

1270 return ApdbCassandraAdmin(self) 

1271 

1272 def _getSources( 

1273 self, 

1274 region: sphgeom.Region, 

1275 object_ids: Iterable[int] | None, 

1276 mjd_start: float, 

1277 mjd_end: float, 

1278 table_name: ApdbTables, 

1279 ) -> pandas.DataFrame: 

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

1281 

1282 Parameters 

1283 ---------- 

1284 region : `lsst.sphgeom.Region` 

1285 Spherical region. 

1286 object_ids : 

1287 Collection of DiaObject IDs 

1288 mjd_start : `float` 

1289 Lower bound of time interval. 

1290 mjd_end : `float` 

1291 Upper bound of time interval. 

1292 table_name : `ApdbTables` 

1293 Name of the table. 

1294 

1295 Returns 

1296 ------- 

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

1298 Catalog containing DiaSource records. Empty catalog is returned if 

1299 ``object_ids`` is empty. 

1300 """ 

1301 context = self._context 

1302 config = context.config 

1303 

1304 object_id_set: Set[int] = set() 

1305 if object_ids is not None: 

1306 object_id_set = set(object_ids) 

1307 if len(object_id_set) == 0: 

1308 return self._make_empty_catalog(table_name) 

1309 

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

1311 tables, temporal_where = context.partitioner.temporal_where( 

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

1313 ) 

1314 if not tables: 

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

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

1317 warnings.warn( 

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

1319 ) 

1320 

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

1322 column_names = context.schema.apdbColumnNames(table_name) 

1323 

1324 # Build all queries 

1325 statements: list[tuple] = [] 

1326 for table in tables: 

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

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

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

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

1331 

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

1333 table_data_raw = cast( 

1334 ApdbCassandraTableData, 

1335 select_concurrent( 

1336 context.session, 

1337 statements, 

1338 "read_raw_multi", 

1339 config.connection_config.read_concurrency, 

1340 ), 

1341 ) 

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

1343 timer.add_values( 

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

1345 ) 

1346 

1347 # filter by given object IDs 

1348 if len(object_id_set) > 0: 

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

1350 

1351 # precise filtering on midpointMjdTai 

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

1353 

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

1355 

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

1357 return catalog 

1358 

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

1360 context = self._context 

1361 config = context.config 

1362 

1363 # Cassandra timestamp uses milliseconds since epoch 

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

1365 

1366 # everything goes into a single partition 

1367 partition = 0 

1368 

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

1370 

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

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

1373 if context.has_chunk_sub_partitions: 

1374 columns.append("has_subchunks") 

1375 values.append(True) 

1376 

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

1378 stmt = context.stmt_factory(query) 

1379 

1380 context.session.execute( 

1381 stmt, 

1382 values, 

1383 timeout=config.connection_config.write_timeout, 

1384 execution_profile="write", 

1385 ) 

1386 

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

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

1389 context = self._context 

1390 config = context.config 

1391 

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

1393 queries = [] 

1394 object_count = 0 

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

1396 id_chunk_list = tuple(id_chunk) 

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

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

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

1400 object_count += len(id_chunk_list) 

1401 

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

1403 data = cast( 

1404 ApdbTableData, 

1405 select_concurrent( 

1406 context.session, 

1407 queries, 

1408 "read_raw_multi", 

1409 config.connection_config.read_concurrency, 

1410 ), 

1411 ) 

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

1413 

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

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

1416 

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

1418 

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

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

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

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

1423 ones. 

1424 """ 

1425 context = self._context 

1426 

1427 # Extract all object IDs. 

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

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

1430 

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

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

1433 new_part = new_partitions.get(oid, old_part) 

1434 if new_part != old_part: 

1435 moved_oids[oid] = (old_part, new_part) 

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

1437 

1438 if moved_oids: 

1439 # Delete old records from DiaObjectLast. 

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

1441 query = Delete(self._keyspace, table_name) 

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

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

1444 queries = [] 

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

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

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

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

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

1450 

1451 # Add all new records to the map. 

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

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

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

1455 

1456 queries = [] 

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

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

1459 

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

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

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

1463 

1464 def _storeDiaObjects( 

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

1466 ) -> None: 

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

1468 

1469 Parameters 

1470 ---------- 

1471 objs : `pandas.DataFrame` 

1472 Catalog with DiaObject records 

1473 visit_time : `astropy.time.Time` 

1474 Time of the current visit. 

1475 replica_chunk : `ReplicaChunk` or `None` 

1476 Replica chunk identifier if replication is configured. 

1477 """ 

1478 if len(objs) == 0: 

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

1480 return 

1481 

1482 context = self._context 

1483 config = context.config 

1484 

1485 self._deleteMovingObjects(objs) 

1486 

1487 validity_start_column = self._timestamp_column_name("validityStart") 

1488 timestamp = self._timestamp_column_value(visit_time) 

1489 

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

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

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

1493 extra_columns[validity_start_column] = timestamp 

1494 

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

1496 

1497 extra_columns[validity_start_column] = timestamp 

1498 visit_time_part = context.partitioner.time_partition(visit_time) 

1499 time_part: int | None = visit_time_part 

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

1501 self._check_time_partitions([visit_time_part], time_partitions_range) 

1502 if not config.partitioning.time_partition_tables: 

1503 extra_columns["apdb_time_part"] = time_part 

1504 time_part = None 

1505 

1506 # Only store DiaObects if not doing replication or explicitly 

1507 # configured to always store them. 

1508 if replica_chunk is None or not config.replica_skips_diaobjects: 

1509 self._storeObjectsPandas( 

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

1511 ) 

1512 

1513 if replica_chunk is not None: 

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

1515 table = ExtraTables.DiaObjectChunks 

1516 if context.has_chunk_sub_partitions: 

1517 table = ExtraTables.DiaObjectChunks2 

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

1519 # that different clients could wrtite to different partitions. 

1520 # This makes it not exactly reproducible. 

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

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

1523 

1524 # Store copy of the records in dedup table. 

1525 if context.has_dedup_table: 

1526 table = ExtraTables.DiaObjectDedup 

1527 extra_columns = { 

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

1529 validity_start_column: timestamp, 

1530 } 

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

1532 

1533 def _storeDiaSources( 

1534 self, 

1535 table_name: ApdbTables, 

1536 sources: pandas.DataFrame, 

1537 replica_chunk: ReplicaChunk | None, 

1538 ) -> int | None: 

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

1540 

1541 Parameters 

1542 ---------- 

1543 table_name : `ApdbTables` 

1544 Table where to store the data. 

1545 sources : `pandas.DataFrame` 

1546 Catalog containing DiaSource records 

1547 visit_time : `astropy.time.Time` 

1548 Time of the current visit. 

1549 replica_chunk : `ReplicaChunk` or `None` 

1550 Replica chunk identifier if replication is configured. 

1551 

1552 Returns 

1553 ------- 

1554 subchunk : `int` or `None` 

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

1556 not enabled ot subchunking is not enabled. 

1557 """ 

1558 context = self._context 

1559 config = context.config 

1560 

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

1562 # as visit_time is not really a visit time. 

1563 tp_sources = sources.copy(deep=False) 

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

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

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

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

1568 if not config.partitioning.time_partition_tables: 

1569 self._storeObjectsPandas(tp_sources, table_name) 

1570 else: 

1571 # Group by time partition 

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

1573 if len(partitions) == 1: 

1574 # Single partition - just save the whole thing. 

1575 time_part = partitions.pop() 

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

1577 else: 

1578 # group by time partition. 

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

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

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

1582 

1583 subchunk: int | None = None 

1584 if replica_chunk is not None: 

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

1586 if context.has_chunk_sub_partitions: 

1587 subchunk = random.randrange(config.replica_sub_chunk_count) 

1588 extra_columns["apdb_replica_subchunk"] = subchunk 

1589 if table_name is ApdbTables.DiaSource: 

1590 extra_table = ExtraTables.DiaSourceChunks2 

1591 else: 

1592 extra_table = ExtraTables.DiaForcedSourceChunks2 

1593 else: 

1594 if table_name is ApdbTables.DiaSource: 

1595 extra_table = ExtraTables.DiaSourceChunks 

1596 else: 

1597 extra_table = ExtraTables.DiaForcedSourceChunks 

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

1599 

1600 return subchunk 

1601 

1602 def _check_time_partitions( 

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

1604 ) -> None: 

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

1606 

1607 Parameters 

1608 ---------- 

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

1610 Time partitions for new data. 

1611 time_partitions_range : `ApdbCassandraTimePartitionRange` 

1612 Currrent time partition range. 

1613 """ 

1614 partitions = set(partitions) 

1615 min_part = min(partitions) 

1616 max_part = max(partitions) 

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

1618 raise ValueError( 

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

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

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

1622 ) 

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

1624 if max_part == time_partitions_range.end: 

1625 warnings.warn( 

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

1627 stacklevel=3, 

1628 ) 

1629 

1630 def _storeDiaSourcesPartitions( 

1631 self, 

1632 sources: pandas.DataFrame, 

1633 visit_time: astropy.time.Time, 

1634 replica_chunk: ReplicaChunk | None, 

1635 subchunk: int | None, 

1636 ) -> None: 

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

1638 

1639 Parameters 

1640 ---------- 

1641 sources : `pandas.DataFrame` 

1642 Catalog containing DiaSource records 

1643 visit_time : `astropy.time.Time` 

1644 Time of the current visit. 

1645 replica_chunk : `ReplicaChunk` or `None` 

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

1647 subchunk : `int` or `None` 

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

1649 sub-chunking is not used. 

1650 """ 

1651 context = self._context 

1652 

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

1654 extra_columns = { 

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

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

1657 } 

1658 if context.has_chunk_sub_partitions: 

1659 extra_columns["apdb_replica_subchunk"] = subchunk 

1660 

1661 self._storeObjectsPandas( 

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

1663 ) 

1664 

1665 def _storeObjectsPandas( 

1666 self, 

1667 records: pandas.DataFrame, 

1668 table_name: ApdbTables | ExtraTables, 

1669 extra_columns: Mapping | None = None, 

1670 time_part: int | None = None, 

1671 ) -> None: 

1672 """Store generic objects. 

1673 

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

1675 

1676 Parameters 

1677 ---------- 

1678 records : `pandas.DataFrame` 

1679 Catalog containing object records 

1680 table_name : `ApdbTables` 

1681 Name of the table as defined in APDB schema. 

1682 extra_columns : `dict`, optional 

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

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

1685 columns exist there. 

1686 time_part : `int`, optional 

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

1688 

1689 Notes 

1690 ----- 

1691 If Pandas catalog contains additional columns not defined in table 

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

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

1694 in a catalog or ``extra_columns``. 

1695 """ 

1696 context = self._context 

1697 

1698 # use extra columns if specified 

1699 if extra_columns is None: 

1700 extra_columns = {} 

1701 extra_fields = list(extra_columns.keys()) 

1702 

1703 # Fields that will come from dataframe. 

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

1705 

1706 column_map = context.schema.getColumnMap(table_name) 

1707 # list of columns (as in felis schema) 

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

1709 fields += extra_fields 

1710 

1711 # check that all partitioning and clustering columns are defined 

1712 partition_columns = context.schema.partitionColumns(table_name) 

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

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

1715 if missing_columns: 

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

1717 

1718 batch_size = self._batch_size(table_name) 

1719 

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

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

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

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

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

1725 values = [] 

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

1727 for field in df_fields: 

1728 if field not in column_map: 

1729 continue 

1730 value = getattr(rec, field) 

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

1732 if isinstance(value, pandas.Timestamp): 

1733 value = value.to_pydatetime() 

1734 elif value is pandas.NaT: 

1735 value = None 

1736 else: 

1737 # Assume it's seconds since epoch, Cassandra 

1738 # datetime is in milliseconds 

1739 value = int(value * 1000) 

1740 value = literal(value) 

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

1742 if field in partition_columns: 

1743 partitioning_values[field] = value 

1744 for field in extra_fields: 

1745 value = literal(extra_columns[field]) 

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

1747 if field in partition_columns: 

1748 partitioning_values[field] = value 

1749 

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

1751 values_by_key[key].append(values) 

1752 

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

1754 

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

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

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

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

1759 queries = [] 

1760 for key_values in values_by_key.values(): 

1761 for values_chunk in chunk_iterable(key_values, batch_size): 

1762 batch = cassandra.query.BatchStatement() 

1763 for row_values in values_chunk: 

1764 batch.add(statement, row_values) 

1765 queries.append((batch, None)) 

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

1767 

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

1769 with self._timer( 

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

1771 ) as timer: 

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

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

1774 

1775 def _storeUpdateRecords( 

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

1777 ) -> None: 

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

1779 

1780 Parameters 

1781 ---------- 

1782 records : `list` [`ApdbUpdateRecord`] 

1783 Records to store. 

1784 chunk : `ReplicaChunk` 

1785 Replica chunk for these records. 

1786 store_chunk : `bool` 

1787 If True then also store replica chunk. 

1788 

1789 Raises 

1790 ------ 

1791 TypeError 

1792 Raised if replication is not enabled for this instance. 

1793 """ 

1794 context = self._context 

1795 config = context.config 

1796 

1797 if not context.schema.replication_enabled: 

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

1799 

1800 if store_chunk: 

1801 self._storeReplicaChunk(chunk) 

1802 

1803 apdb_replica_chunk = chunk.id 

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

1805 # multiple calls to this method. 

1806 update_unique_id = uuid.uuid4() 

1807 

1808 rows = [] 

1809 for record in records: 

1810 rows.append( 

1811 [ 

1812 apdb_replica_chunk, 

1813 record.update_time_ns, 

1814 record.update_order, 

1815 update_unique_id, 

1816 record.to_json(), 

1817 ] 

1818 ) 

1819 columns = [ 

1820 "apdb_replica_chunk", 

1821 "update_time_ns", 

1822 "update_order", 

1823 "update_unique_id", 

1824 "update_payload", 

1825 ] 

1826 if context.has_chunk_sub_partitions: 

1827 subchunk = random.randrange(config.replica_sub_chunk_count) 

1828 for row in rows: 

1829 row.append(subchunk) 

1830 columns.append("apdb_replica_subchunk") 

1831 

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

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

1834 stmt = context.stmt_factory(query) 

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

1836 

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

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

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

1840 

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

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

1843 DataFrame. 

1844 

1845 Parameters 

1846 ---------- 

1847 df : `pandas.DataFrame` 

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

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

1850 

1851 Returns 

1852 ------- 

1853 df : `pandas.DataFrame` 

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

1855 for ra/dec coordinates. 

1856 

1857 Notes 

1858 ----- 

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

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

1861 is returned. 

1862 """ 

1863 context = self._context 

1864 config = context.config 

1865 

1866 # Calculate pixelization index for every record. 

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

1868 ra_col, dec_col = config.ra_dec_columns 

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

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

1871 apdb_part[i] = idx 

1872 df = df.copy() 

1873 df["apdb_part"] = apdb_part 

1874 return df 

1875 

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

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

1878 

1879 Parameters 

1880 ---------- 

1881 table_name : `ApdbTables` 

1882 Name of the table. 

1883 

1884 Returns 

1885 ------- 

1886 catalog : `pandas.DataFrame` 

1887 An empty catalog. 

1888 """ 

1889 table = self.schema.tableSchemas[table_name] 

1890 

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

1892 return pandas.DataFrame(data) 

1893 

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

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

1896 type. 

1897 

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

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

1900 drop timezone. 

1901 """ 

1902 # Find all columns with aware timestamps. 

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

1904 for column in columns: 

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

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

1907 return df 

1908 

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

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

1911 context = self._context 

1912 config = context.config 

1913 

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

1915 batch_size = 65_535 

1916 if 0 < config.batch_statement_limit < batch_size: 

1917 batch_size = config.batch_statement_limit 

1918 if config.batch_size_limit > 0: 

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

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

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

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

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

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

1925 # ignore. 

1926 row_size = context.schema.table_row_size(table) 

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

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

1929 return batch_size 

1930 

1931 def _group_dia_objects_by_partition( 

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

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

1934 """Group DiaObjects by partition. 

1935 

1936 Parameters 

1937 ---------- 

1938 partitioner : `Partitioner` 

1939 Objects which knows how to partition things. 

1940 objects : `list` [`DiaObjectId`] 

1941 Collection of objects to partition. 

1942 pad_arcsec : `float` 

1943 Additional padding around object position. 

1944 

1945 Returns 

1946 ------- 

1947 grouped_objects 

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

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

1950 """ 

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

1952 for obj_id in objects: 

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

1954 for pixel in partitions: 

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

1956 return partitioned_object_ids 

1957 

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

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

1960 return self._schema.timestamp_column_name(column) 

1961 

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

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

1964 if self._schema.has_mjd_timestamps: 

1965 return float(time.tai.mjd) 

1966 else: 

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

1968 

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

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

1971 records as a list of named tuples. 

1972 """ 

1973 context = self._context 

1974 config = context.config 

1975 partitioner = context.partitioner 

1976 

1977 columns = ("diaSourceId",) + columns 

1978 

1979 # Allow some uncertainty for coordinates and time when calculating 

1980 # partitions. 

1981 statements: list[tuple] = [] 

1982 pad_arcsec = 1.0 

1983 pad_time_day = 10 / (24 * 3600) 

1984 for source_id in source_ids: 

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

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

1987 spatial_where, _ = partitioner.spatial_where(region) 

1988 

1989 tables, temporal_where = partitioner.temporal_where( 

1990 ApdbTables.DiaSource, 

1991 source_id.midpointMjdTai - pad_time_day, 

1992 source_id.midpointMjdTai + pad_time_day, 

1993 partitons_range=context.time_partitions_range, 

1994 query_per_time_part=True, 

1995 ) 

1996 

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

1998 

1999 for table in tables: 

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

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

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

2003 

2004 with self._timer( 

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

2006 ) as timer: 

2007 result = cast( 

2008 list[tuple], 

2009 select_concurrent( 

2010 context.session, 

2011 statements, 

2012 "read_named_tuples", 

2013 config.connection_config.read_concurrency, 

2014 ), 

2015 ) 

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

2017 

2018 return result 

2019 

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

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

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

2023 """ 

2024 context = self._context 

2025 config = context.config 

2026 partitioner = context.partitioner 

2027 

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

2029 

2030 # Allow some uncertainty for coordinates and time when calculating 

2031 # partitions. 

2032 statements: list[tuple] = [] 

2033 pad_arcsec = 1.0 

2034 pad_time_day = 10 / (24 * 3600) 

2035 for source_id in source_ids: 

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

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

2038 spatial_where, _ = partitioner.spatial_where(region) 

2039 

2040 tables, temporal_where = partitioner.temporal_where( 

2041 ApdbTables.DiaForcedSource, 

2042 source_id.midpointMjdTai - pad_time_day, 

2043 source_id.midpointMjdTai + pad_time_day, 

2044 partitons_range=context.time_partitions_range, 

2045 query_per_time_part=True, 

2046 ) 

2047 

2048 id_where = ( 

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

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

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

2052 ) 

2053 

2054 for table in tables: 

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

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

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

2058 

2059 with self._timer( 

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

2061 ) as timer: 

2062 result = cast( 

2063 list[tuple], 

2064 select_concurrent( 

2065 context.session, 

2066 statements, 

2067 "read_named_tuples", 

2068 config.connection_config.read_concurrency, 

2069 ), 

2070 ) 

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

2072 

2073 return result 

2074 

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

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

2077 all records as a list of named tuples. 

2078 """ 

2079 context = self._context 

2080 config = context.config 

2081 partitioner = context.partitioner 

2082 

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

2084 columns = ("diaObjectId",) + columns 

2085 

2086 # Allow some uncertainty for coordinates when calculating partitions. 

2087 pad_arcsec = 1.0 

2088 ids_by_partition = defaultdict(list) 

2089 for object_id in object_ids: 

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

2091 for pixel in pixels: 

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

2093 

2094 statements: list[tuple] = [] 

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

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

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

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

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

2100 

2101 with self._timer( 

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

2103 ) as timer: 

2104 result = cast( 

2105 list[tuple], 

2106 select_concurrent( 

2107 context.session, 

2108 statements, 

2109 "read_named_tuples", 

2110 config.connection_config.read_concurrency, 

2111 ), 

2112 ) 

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

2114 

2115 return result 

2116 

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

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

2119 

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

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

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

2123 """ 

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

2125 table_schema = self._schema.tableSchemas[table] 

2126 defaults = { 

2127 column.name: column.value 

2128 for column in table_schema.columns 

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

2130 } 

2131 if defaults: 

2132 df.fillna(defaults, inplace=True)