Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/db/transport.py: 69%

206 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 15:02 +0000

1""" 

2Datastore transport layer: the process-wide client and the CRUD helpers. 

3 

4**Named database and namespace** 

5 

6The datastore client (:data:`__client__`) is built once at import time and 

7kept for the whole process lifetime. Its target database and namespace come 

8from :attr:`conf.db.name <viur.core.config.Database.name>` and 

9:attr:`conf.db.namespace <viur.core.config.Database.namespace>`, which are sourced 

10from the ``VIUR_DB_NAME`` / ``VIUR_DB_NAMESPACE`` environment variables. Both 

11default to ``None`` — the standard ``(default)`` database and empty namespace — 

12so existing deployments are unaffected. 

13 

14Because the client is created from the environment at import time, the target 

15cannot be retargeted at runtime: a single process always talks to exactly one 

16database. :class:`~viur.core.db.types.Key` objects inherit that database and 

17namespace from the client, keeping every request on the configured target. 

18 

19The legacy urlsafe key encoding (App Engine "Reference") predates named 

20databases and only supports the default one. Therefore 

21:meth:`Key.to_legacy_urlsafe <viur.core.db.types.Key.to_legacy_urlsafe>` 

22encodes a database-less copy of the key, and the client's database is restored 

23on decoding — unambiguous precisely because the process is bound to a single 

24database. 

25""" 

26from __future__ import annotations 

27 

28import contextvars 

29import itertools 

30import logging 

31import time 

32import typing as t 

33 

34from deprecated.sphinx import deprecated 

35from google.cloud import datastore, exceptions 

36 

37from .overrides import entity_from_protobuf, key_from_protobuf 

38from .types import Entity, Key, QueryDefinition, SortOrder, current_db_access_log 

39from . import cache 

40from viur.core.config import conf 

41 

42# patching our key and entity classes 

43datastore.helpers.key_from_protobuf = key_from_protobuf 

44datastore.helpers.entity_from_protobuf = entity_from_protobuf 

45 

46# Built once at import, kept for the process lifetime — so db/namespace have to 

47# come from env (via conf.db); nothing can retarget the client afterwards. 

48# Both default to None, which is the same as datastore.Client(): no change for 

49# default deployments. 

50__client__ = datastore.Client(database=conf.db.name, namespace=conf.db.namespace) 

51 

52_transaction_outdated: contextvars.ContextVar[list[t.Union[Entity, Key]] | None] = contextvars.ContextVar( 

53 "Transaction-outdated-entities", default=None 

54) 

55"""Entities and keys the transaction running in this context has outdated. 

56 

57The cache cannot be updated while a transaction is open -- the write is not 

58committed yet and may still roll back -- so :func:`put` and :func:`delete` 

59record what they touched here and :func:`run_in_transaction` invalidates those 

60entries once the transaction is over. 

61""" 

62 

63 

64def _mark_as_outdated(data: t.Union[Entity, Key, t.Iterable[t.Union[Entity, Key]]]) -> None: 

65 """Record entities/keys whose cache entry a running transaction outdates.""" 

66 if (outdated := _transaction_outdated.get()) is None: 

67 return # no transaction in this context, the caller updates the cache itself 

68 

69 outdated.extend(data if isinstance(data, (list, set, tuple)) else [data]) 

70 

71 

72def _invalidate_outdated(outdated: list[t.Union[Entity, Key]]) -> None: 

73 """Drop everything a finished transaction outdated from the cache. 

74 

75 The keys are read *after* the transaction, so entities written with a 

76 partial key carry their final, datastore-assigned key by now. 

77 """ 

78 keys = [entry.key if isinstance(entry, Entity) else entry for entry in outdated] 

79 if keys := [key for key in keys if key is not None and not key.is_partial]: 79 ↛ exitline 79 didn't return from function '_invalidate_outdated' because the condition on line 79 was always true

80 cache.delete(keys) 

81 

82 

83MAX_LOOKUP_KEYS: t.Final[int] = 1000 

84"""Maximum number of keys the datastore accepts for a single Lookup operation. 

85 

86Unlike a Lookup, a Commit has no comparable cap on the number of mutations - it is bounded by 

87the 10 MiB request size instead. :func:`put` and :func:`delete` therefore stay a single commit 

88of whatever they are handed, which keeps them atomic. 

89""" 

90 

91 

92def allocate_ids(kind_name: str, num_ids: int = 1, retry=None, timeout=None) -> list[Key]: 

93 if type(kind_name) is not str: 

94 raise TypeError("kind_name must be a string") 

95 return __client__.allocate_ids(Key(kind_name), num_ids, retry, timeout) 

96 

97 

98@deprecated(version="3.8.0", reason="Use 'db.allocate_ids' instead") 

99def AllocateIDs(kind_name): 

100 """ 

101 Allocates a new, free unique id for a given kind_name. 

102 """ 

103 if isinstance(kind_name, Key): # so ein Murks... 

104 kind_name = kind_name.kind 

105 

106 return allocate_ids(kind_name)[0] 

107 

108 

109def get(keys: t.Union[Key, t.Iterable[Key]]) -> t.Union[list[Entity], Entity, None]: 

110 """ 

111 Retrieves an entity (or a list thereof) from datastore. 

112 If only a single key has been given we'll return the entity or none in case the key has not been found, 

113 otherwise a list of all entities that have been looked up (which may be empty) 

114 :param keys: A datastore key (or a list thereof) to lookup 

115 :return: The entity (or None if it has not been found), or a list of entities. 

116 """ 

117 _write_to_access_log(keys) 

118 

119 is_multiple = isinstance(keys, (list, set, tuple)) 

120 key_list = list(keys) if is_multiple else [keys] 

121 

122 # Serve whatever we can from the cache, indexed by its stringified key. 

123 entities_by_key = {str(entity.key): entity for entity in cache.get(key_list)} 

124 

125 # Fetch the keys that were not cached and write them back into the cache 

126 missing = [key for key in key_list if str(key) not in entities_by_key] 

127 if missing: 

128 # A Lookup accepts at most MAX_LOOKUP_KEYS keys, so ask in chunks and merge the answers 

129 fetched = [] 

130 for chunk in itertools.batched(missing, MAX_LOOKUP_KEYS): 

131 fetched.extend(__client__.get_multi(list(chunk))) 

132 if fetched: 132 ↛ 134line 132 didn't jump to line 134 because the condition on line 132 was always true

133 cache.put(fetched) 

134 for entity in fetched: 

135 entities_by_key[str(entity.key)] = entity 

136 

137 # Reassemble in the original key order, dropping keys that were not found 

138 result = [entities_by_key[str(key)] for key in key_list if str(key) in entities_by_key] 

139 

140 if conf.debug.trace_queries: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true

141 logging.info(f"db.get: {len(result)}/{len(key_list)} entities found") 

142 

143 if is_multiple: 

144 return result 

145 return result[0] if result else None 

146 

147 

148 

149@deprecated(version="3.8.0", reason="Use 'db.get' instead") 

150def Get(keys: t.Union[Key, t.List[Key]]) -> t.Union[t.List[Entity], Entity, None]: 

151 return get(keys) 

152 

153 

154def put(entities: t.Union[Entity, t.List[Entity]]): 

155 """ 

156 Save an entity in the Cloud Datastore. 

157 Also ensures that no string-key with a digit-only name can be used. 

158 

159 A list of entities is written in one commit and therefore atomically, however long it is. 

160 

161 :param entities: The entities to be saved to the datastore. 

162 """ 

163 _write_to_access_log(entities) 

164 

165 # Cache only after the datastore accepted the write: a failed write must not 

166 # leave a value in the cache that was never persisted. The datastore also 

167 # completes partial keys during the write, so caching afterwards stores the 

168 # entity under its final key. 

169 if isinstance(entities, Entity): 169 ↛ 174line 169 didn't jump to line 174 because the condition on line 169 was always true

170 res = __client__.put(entities) 

171 if conf.debug.trace_queries: 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true

172 logging.info(f"db.put: saved {entities.key}") 

173 else: 

174 res = __client__.put_multi(entities=entities) 

175 if conf.debug.trace_queries: 

176 logging.info(f"db.put: saved {len(entities)} entities") 

177 

178 # Inside a transaction cache.put() is a no-op (nothing is committed yet), so 

179 # remember the entities and let run_in_transaction() invalidate them afterwards. 

180 _mark_as_outdated(entities) 

181 cache.put(entities) 

182 return res 

183 

184 

185@deprecated(version="3.8.0", reason="Use 'db.put' instead") 

186def Put(entities: t.Union[Entity, t.List[Entity]]) -> t.Union[Entity, None]: 

187 return put(entities) 

188 

189 

190def delete(keys: t.Union[Entity, t.Iterable[Entity], Key, t.Iterable[Key]]): 

191 """ 

192 Deletes the entities with the given key(s) from the datastore. 

193 

194 A list of keys is deleted in one commit and therefore atomically, however long it is. 

195 

196 :param keys: A Key (or a t.List of Keys) to delete 

197 """ 

198 

199 _write_to_access_log(keys) 

200 # Invalidate right away, and again once a surrounding transaction has finished: 

201 # until then a concurrent read could pull the still-current value back in. 

202 _mark_as_outdated(keys) 

203 cache.delete(keys) 

204 if not isinstance(keys, (set, list, tuple)): 204 ↛ 211line 204 didn't jump to line 211 because the condition on line 204 was always true

205 res = __client__.delete(keys) 

206 if conf.debug.trace_queries: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 logging.info(f"db.delete: deleted {keys}") 

208 return res 

209 

210 

211 res = __client__.delete_multi(keys) 

212 if conf.debug.trace_queries: 

213 logging.info(f"db.delete: deleted {len(keys)} keys") 

214 return res 

215 

216 

217@deprecated(version="3.8.0", reason="Use 'db.delete' instead") 

218def Delete(keys: t.Union[Entity, t.List[Entity], Key, t.List[Key]]): 

219 return delete(keys) 

220 

221 

222def run_in_transaction(func: t.Callable, *args, **kwargs) -> t.Any: 

223 """ 

224 Runs the function given in :param:callee inside a transaction. 

225 Inside a transaction it's guaranteed that 

226 - either all or no changes are written to the datastore 

227 - no other transaction is currently reading/writing the entities accessed 

228 

229 See (transactions)[https://cloud.google.com/datastore/docs/concepts/cloud-datastore-transactions] for more 

230 information. 

231 

232 ..Warning: The datastore may produce unexpected results if an entity that have been written inside a transaction 

233 is read (or returned in a query) again. In this case you will the the *old* state of that entity. Keep that 

234 in mind if wrapping functions to run in a transaction that may have not been designed to handle this case. 

235 :param func: The function that will be run inside a transaction 

236 :param args: All args will be passed into the callee 

237 :param kwargs: All kwargs will be passed into the callee 

238 :return: Whatever the callee function returned 

239 :raises RuntimeError: If the maximum transaction retries exceeded 

240 """ 

241 if __client__.current_transaction: 241 ↛ 243line 241 didn't jump to line 243 because the condition on line 241 was never true

242 # Nested call: the outermost one owns the cache invalidation. 

243 return func(*args, **kwargs) 

244 

245 token = _transaction_outdated.set([]) 

246 try: 

247 for i in range(conf.db.transaction_attempts): 247 ↛ 263line 247 didn't jump to line 263 because the loop on line 247 didn't complete

248 try: 

249 with __client__.transaction(): 

250 res = func(*args, **kwargs) 

251 break 

252 

253 except exceptions.Conflict: 

254 if i + 1 >= conf.db.transaction_attempts: 

255 # Last attempt failed: raise right away instead of sleeping first. 

256 raise RuntimeError("Maximum transaction retries exceeded") 

257 

258 logging.error(f"Transaction failed with a conflict, trying again in {2 ** i} seconds") 

259 time.sleep(2 ** i) 

260 continue 

261 

262 else: 

263 raise RuntimeError("Maximum transaction retries exceeded") 

264 

265 finally: 

266 # Also invalidate when the transaction failed: a retry may have written 

267 # before the conflict, and one invalidation too many only costs a lookup. 

268 outdated = _transaction_outdated.get() 

269 _transaction_outdated.reset(token) 

270 if outdated: 

271 _invalidate_outdated(outdated) 

272 

273 return res 

274 

275 

276@deprecated(version="3.8.0", reason="Use 'db.run_in_transaction' instead") 

277def RunInTransaction(callee: t.Callable, *args, **kwargs) -> t.Any: 

278 return run_in_transaction(callee, *args, **kwargs) 

279 

280 

281NATIVE_FILTER_OPERATORS = frozenset({"IN", "!=", "NOT_IN"}) 

282"""Operators the Datastore evaluates natively; their value goes into a single PropertyFilter, unsplit.""" 

283 

284 

285def _normalize_filter_value(op: str, value: t.Any) -> t.Any: 

286 """ 

287 google-cloud-datastore only encodes a ``list`` as array value, any other collection raises 

288 ``ValueError: Unknown protobuf attr type``. Convert tuples and sets for the array operators. 

289 The order of a set is kept as it is; IN and NOT_IN don't depend on it. 

290 """ 

291 if op in ("IN", "NOT_IN") and isinstance(value, (tuple, set, frozenset)): 

292 return list(value) 

293 

294 return value 

295 

296 

297def _build_property_filters(key: str, op: str, value: t.Any) -> list[datastore.query.PropertyFilter]: 

298 """ 

299 Build the PropertyFilters for one entry of :attr:`QueryDefinition.filters`. 

300 

301 Native operators result in exactly one filter; any other operator with a list value 

302 results in one filter per element (multi equal filters). 

303 """ 

304 if op in NATIVE_FILTER_OPERATORS: 304 ↛ 307line 304 didn't jump to line 307 because the condition on line 304 was always true

305 return [datastore.query.PropertyFilter(key, op, _normalize_filter_value(op, value))] 

306 

307 if not isinstance(value, list): 

308 value = [value] 

309 

310 return [datastore.query.PropertyFilter(key, op, val) for val in value] 

311 

312 

313def _build_or_filter(or_group: list[tuple[str, t.Any]]) -> datastore.query.Or: 

314 """ 

315 Build the Or composite filter for one entry of :attr:`QueryDefinition.or_filters`. 

316 """ 

317 or_conditions = [] 

318 for filter_str, value in or_group: 

319 key, op = filter_str.split(" ", 1) 

320 or_conditions.append(datastore.query.PropertyFilter(key, op, _normalize_filter_value(op, value))) 

321 

322 return datastore.query.Or(or_conditions) 

323 

324 

325def count(kind: str = None, up_to=2 ** 31 - 1, queryDefinition: QueryDefinition = None) -> int: 

326 if not kind: 326 ↛ 329line 326 didn't jump to line 329 because the condition on line 326 was always true

327 kind = queryDefinition.kind 

328 

329 query = __client__.query(kind=kind) 

330 if queryDefinition and queryDefinition.filters: 

331 for k, v in queryDefinition.filters.items(): 

332 key, op = k.split(" ") 

333 for f in _build_property_filters(key, op, v): 

334 query.add_filter(filter=f) 

335 

336 if queryDefinition and queryDefinition.or_filters: 

337 for or_group in queryDefinition.or_filters: 

338 query.add_filter(filter=_build_or_filter(or_group)) 

339 

340 aggregation_query = __client__.aggregation_query(query) 

341 

342 result = aggregation_query.count(alias="total").fetch(limit=up_to) 

343 return list(result)[0][0].value 

344 

345 

346@deprecated(version="3.8.0", reason="Use 'db.count' instead") 

347def Count(kind: str = None, up_to=2 ** 31 - 1, queryDefinition: QueryDefinition = None) -> int: 

348 return count(kind, up_to, queryDefinition) 

349 

350 

351def run_single_filter(query: QueryDefinition, limit: int, keys_only: bool) -> t.List[Entity | Key]: 

352 """ 

353 Internal helper function that runs a single query definition on the datastore and returns a list of 

354 entities found. 

355 :param query: The querydefinition (filters, orders, distinct etc.) to run against the datastore 

356 :param limit: How many results should at most be returned 

357 :return: The first *limit* entities that matches this query 

358 """ 

359 

360 qry = __client__.query(kind=query.kind) 

361 startCursor = None 

362 endCursor = None 

363 hasInvertedOrderings = None 

364 if conf.debug.trace_queries: 364 ↛ 365line 364 didn't jump to line 365 because the condition on line 364 was never true

365 logging.info(f"Running query: {query}") 

366 

367 if query: 367 ↛ 393line 367 didn't jump to line 393 because the condition on line 367 was always true

368 if query.filters: 

369 for k, v in query.filters.items(): 

370 key, op = k.split(" ") 

371 for f in _build_property_filters(key, op, v): 

372 qry.add_filter(filter=f) 

373 

374 if query.or_filters: 

375 for or_group in query.or_filters: 

376 qry.add_filter(filter=_build_or_filter(or_group)) 

377 

378 if query.orders: 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was never true

379 hasInvertedOrderings = any( 

380 order.order in (SortOrder.InvertedAscending, SortOrder.InvertedDescending) 

381 for order in query.orders 

382 ) 

383 qry.order = [ 

384 order.name if order.order in (SortOrder.Ascending, SortOrder.InvertedDescending) else f"-{order.name}" 

385 for order in query.orders 

386 ] 

387 

388 if query.distinct: 388 ↛ 389line 388 didn't jump to line 389 because the condition on line 388 was never true

389 qry.distinct_on = query.distinct 

390 

391 startCursor = query.startCursor 

392 endCursor = query.endCursor 

393 if keys_only: 393 ↛ 394line 393 didn't jump to line 394 because the condition on line 393 was never true

394 qry.keys_only() 

395 qryRes = qry.fetch(limit=limit, start_cursor=startCursor, end_cursor=endCursor) 

396 res = list(qryRes) 

397 query.currentCursor = qryRes.next_page_token 

398 if hasInvertedOrderings: 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true

399 res.reverse() 

400 

401 if conf.debug.trace_queries: 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true

402 distinct_on = f" distinct on {query.distinct}" if query.distinct else "" 

403 logging.debug( 

404 f"Queried {query.kind} with filter {query.filters} and orders {query.orders}{distinct_on}." 

405 f" Returned {len(res)} results" 

406 ) 

407 

408 return res 

409 

410 

411@deprecated(version="3.8.0", reason="Use 'run_single_filter' instead") 

412def runSingleFilter(query: QueryDefinition, limit: int) -> t.List[Entity]: 

413 run_single_filter(query, limit) 

414 

415 

416# helper function for access log 

417def _write_to_access_log(data: t.Union[Key, list[Key], Entity, list[Entity]]) -> None: 

418 if not conf.db.create_access_log: 418 ↛ 419line 418 didn't jump to line 419 because the condition on line 418 was never true

419 return 

420 access_log = current_db_access_log.get() 

421 if not isinstance(access_log, set): 421 ↛ 423line 421 didn't jump to line 423 because the condition on line 421 was always true

422 return # access log not exist 

423 if not data: 

424 return 

425 if isinstance(data, Entity): 

426 access_log.add(data.key) 

427 elif isinstance(data, Key): 

428 access_log.add(data) 

429 else: 

430 for entry in data: 

431 if isinstance(entry, Entity): 

432 access_log.add(entry.key) 

433 elif isinstance(entry, Key): 

434 access_log.add(entry) 

435 

436 

437__all__ = [allocate_ids, delete, get, put, run_in_transaction, count]