|
3 | 3 | import asyncio |
4 | 4 | import functools |
5 | 5 | import importlib |
| 6 | +import threading |
6 | 7 | from datetime import datetime, timezone |
7 | 8 | from logging import getLogger |
8 | 9 | from typing import ( |
9 | 10 | Any, |
10 | 11 | Callable, |
11 | 12 | Dict, |
| 13 | + Iterator, |
12 | 14 | List, |
13 | 15 | Literal, |
14 | 16 | Optional, |
15 | 17 | Sequence, |
16 | 18 | Set, |
17 | 19 | Tuple, |
| 20 | + TypeVar, |
18 | 21 | Union, |
19 | 22 | ) |
20 | 23 |
|
|
88 | 91 | # ordering; subsequent puts keep it. |
89 | 92 | _ORDERED_MAP_POLICY: Dict[str, Any] = {"map_order": aerospike.MAP_KEY_ORDERED} |
90 | 93 |
|
| 94 | +# Aerospike server ``batch-max-requests`` defaults to 5000 on many clusters (0 |
| 95 | +# means unlimited on newer releases). Stay well under that so materialization |
| 96 | +# and wide feature-server requests do not trip BatchMaxRequestError (code 151). |
| 97 | +_DEFAULT_BATCH_MAX_RECORDS: int = 1_000 |
| 98 | + |
| 99 | +_T = TypeVar("_T") |
| 100 | + |
91 | 101 |
|
92 | 102 | def _datetime_to_epoch_ms(dt: datetime) -> int: |
93 | 103 | """Convert a datetime to int64 epoch milliseconds. |
@@ -256,6 +266,15 @@ def hook( |
256 | 266 | max_retries: int = 2 |
257 | 267 | """Maximum number of automatic retries on transient errors.""" |
258 | 268 |
|
| 269 | + batch_max_records: int = _DEFAULT_BATCH_MAX_RECORDS |
| 270 | + """Maximum records per ``batch_write`` / ``batch_operate`` call. |
| 271 | +
|
| 272 | + Aerospike enforces a per-node batch size via the server ``batch-max-requests`` |
| 273 | + setting (historically 5000). Feast chunks read and write paths to this limit |
| 274 | + so large materializations and wide online-serving requests do not fail the |
| 275 | + whole batch when the server cap is exceeded. |
| 276 | + """ |
| 277 | + |
259 | 278 | client_kwargs: Dict[str, Any] = {} |
260 | 279 | """Escape hatch for any Aerospike client configuration not surfaced above. |
261 | 280 | Merged into the client config passed to ``aerospike.client()``.""" |
@@ -290,64 +309,87 @@ def __init__(self) -> None: |
290 | 309 | # store to a different hook) re-resolves on next call. |
291 | 310 | self._prewriting_hook: Optional[PrewritingHook] = None |
292 | 311 | self._prewriting_hook_spec: Optional[str] = None |
| 312 | + self._client_lock = threading.Lock() |
293 | 313 |
|
294 | 314 | # ------------------------------------------------------------------ |
295 | 315 | # Lifecycle / connection management |
296 | 316 | # ------------------------------------------------------------------ |
| 317 | + @staticmethod |
| 318 | + def _chunked(items: Sequence[_T], chunk_size: int) -> Iterator[Sequence[_T]]: |
| 319 | + """Yield slices of ``items`` no larger than ``chunk_size``.""" |
| 320 | + if chunk_size <= 0: |
| 321 | + raise ValueError(f"chunk_size must be positive, got {chunk_size}") |
| 322 | + for start in range(0, len(items), chunk_size): |
| 323 | + yield items[start : start + chunk_size] |
| 324 | + |
| 325 | + def _batch_max_records(self, config: RepoConfig) -> int: |
| 326 | + store_cfg = config.online_store |
| 327 | + if not isinstance(store_cfg, AerospikeOnlineStoreConfig): |
| 328 | + raise RuntimeError(f"{config.online_store.type = }. It must be aerospike.") |
| 329 | + return store_cfg.batch_max_records |
| 330 | + |
297 | 331 | def _get_client(self, config: RepoConfig) -> aerospike.Client: |
298 | 332 | """Lazily create and cache an Aerospike client on first use. |
299 | 333 |
|
300 | 334 | The underlying C client maintains its own connection pool, so a single |
301 | | - cached instance is safe to share across calls on this store. |
| 335 | + cached instance is safe to share across calls on this store. Creation |
| 336 | + is guarded by a lock so concurrent first callers in a threaded feature |
| 337 | + server do not leak extra connections. |
302 | 338 | """ |
303 | 339 | if self._client is not None: |
304 | 340 | return self._client |
305 | 341 |
|
306 | | - if not isinstance(config.online_store, AerospikeOnlineStoreConfig): |
307 | | - raise RuntimeError(f"{config.online_store.type = }. It must be aerospike.") |
308 | | - store_cfg = config.online_store |
| 342 | + with self._client_lock: |
| 343 | + if self._client is not None: |
| 344 | + return self._client |
309 | 345 |
|
310 | | - read_policy: Dict[str, Any] = { |
311 | | - "total_timeout": store_cfg.read_timeout_ms, |
312 | | - "max_retries": store_cfg.max_retries, |
313 | | - } |
314 | | - write_policy: Dict[str, Any] = { |
315 | | - "total_timeout": store_cfg.write_timeout_ms, |
316 | | - "max_retries": store_cfg.max_retries, |
317 | | - } |
318 | | - batch_policy: Dict[str, Any] = { |
319 | | - "total_timeout": store_cfg.batch_total_timeout_ms, |
320 | | - "max_retries": store_cfg.max_retries, |
321 | | - } |
322 | | - if store_cfg.socket_timeout_ms is not None: |
323 | | - # socket_timeout is the per-attempt deadline; without it, |
324 | | - # total_timeout is the whole budget and retries never fire. |
325 | | - read_policy["socket_timeout"] = store_cfg.socket_timeout_ms |
326 | | - write_policy["socket_timeout"] = store_cfg.socket_timeout_ms |
327 | | - batch_policy["socket_timeout"] = store_cfg.socket_timeout_ms |
328 | | - |
329 | | - client_config: Dict[str, Any] = { |
330 | | - "hosts": [tuple(h) for h in store_cfg.hosts], |
331 | | - "policies": { |
332 | | - "read": read_policy, |
333 | | - "write": write_policy, |
334 | | - "batch": batch_policy, |
335 | | - }, |
336 | | - **store_cfg.client_kwargs, |
337 | | - } |
338 | | - if store_cfg.user: |
339 | | - if store_cfg.password is None: |
340 | | - raise ValueError( |
341 | | - "AerospikeOnlineStoreConfig.user is set but password is not." |
| 346 | + if not isinstance(config.online_store, AerospikeOnlineStoreConfig): |
| 347 | + raise RuntimeError( |
| 348 | + f"{config.online_store.type = }. It must be aerospike." |
342 | 349 | ) |
343 | | - client_config["user"] = store_cfg.user |
344 | | - client_config["password"] = store_cfg.password.get_secret_value() |
345 | | - client_config["auth_mode"] = _AUTH_MODE_TO_CONSTANT[store_cfg.auth_mode] |
346 | | - if store_cfg.tls: |
347 | | - client_config["tls"] = store_cfg.tls |
| 350 | + store_cfg = config.online_store |
| 351 | + |
| 352 | + read_policy: Dict[str, Any] = { |
| 353 | + "total_timeout": store_cfg.read_timeout_ms, |
| 354 | + "max_retries": store_cfg.max_retries, |
| 355 | + } |
| 356 | + write_policy: Dict[str, Any] = { |
| 357 | + "total_timeout": store_cfg.write_timeout_ms, |
| 358 | + "max_retries": store_cfg.max_retries, |
| 359 | + } |
| 360 | + batch_policy: Dict[str, Any] = { |
| 361 | + "total_timeout": store_cfg.batch_total_timeout_ms, |
| 362 | + "max_retries": store_cfg.max_retries, |
| 363 | + } |
| 364 | + if store_cfg.socket_timeout_ms is not None: |
| 365 | + # socket_timeout is the per-attempt deadline; without it, |
| 366 | + # total_timeout is the whole budget and retries never fire. |
| 367 | + read_policy["socket_timeout"] = store_cfg.socket_timeout_ms |
| 368 | + write_policy["socket_timeout"] = store_cfg.socket_timeout_ms |
| 369 | + batch_policy["socket_timeout"] = store_cfg.socket_timeout_ms |
| 370 | + |
| 371 | + client_config: Dict[str, Any] = { |
| 372 | + "hosts": [tuple(h) for h in store_cfg.hosts], |
| 373 | + "policies": { |
| 374 | + "read": read_policy, |
| 375 | + "write": write_policy, |
| 376 | + "batch": batch_policy, |
| 377 | + }, |
| 378 | + **store_cfg.client_kwargs, |
| 379 | + } |
| 380 | + if store_cfg.user: |
| 381 | + if store_cfg.password is None: |
| 382 | + raise ValueError( |
| 383 | + "AerospikeOnlineStoreConfig.user is set but password is not." |
| 384 | + ) |
| 385 | + client_config["user"] = store_cfg.user |
| 386 | + client_config["password"] = store_cfg.password.get_secret_value() |
| 387 | + client_config["auth_mode"] = _AUTH_MODE_TO_CONSTANT[store_cfg.auth_mode] |
| 388 | + if store_cfg.tls: |
| 389 | + client_config["tls"] = store_cfg.tls |
348 | 390 |
|
349 | | - self._client = aerospike.client(client_config).connect() |
350 | | - return self._client |
| 391 | + self._client = aerospike.client(client_config).connect() |
| 392 | + return self._client |
351 | 393 |
|
352 | 394 | def _set_name(self, config: RepoConfig, fv_name: Optional[str] = None) -> str: |
353 | 395 | """Resolve the Aerospike set name for a feature view. |
@@ -568,17 +610,23 @@ def online_write_batch( |
568 | 610 | client = self._get_client(config) |
569 | 611 | namespace = self._namespace_for_fv(config, table.name) |
570 | 612 | set_name = self._set_name(config, table.name) |
571 | | - batch = self._build_batch_writes(config, table, data, namespace, set_name) |
572 | | - if batch.batch_records: |
573 | | - client.batch_write(batch) |
574 | | - # Per-record result codes must be inspected: client.batch_write |
575 | | - # only raises if the whole request was rejected. A partial failure |
576 | | - # (e.g. a single-partition timeout) is otherwise silent, which in |
577 | | - # an online-serving path presents downstream as "model saw stale |
578 | | - # features" weeks after the fact. |
579 | | - self._raise_on_batch_errors(batch.batch_records, set_name, op="write") |
580 | | - if progress: |
581 | | - progress(len(data)) |
| 613 | + chunk_size = self._batch_max_records(config) |
| 614 | + written = 0 |
| 615 | + for chunk in self._chunked(data, chunk_size): |
| 616 | + batch = self._build_batch_writes( |
| 617 | + config, table, list(chunk), namespace, set_name |
| 618 | + ) |
| 619 | + if batch.batch_records: |
| 620 | + client.batch_write(batch) |
| 621 | + # Per-record result codes must be inspected: client.batch_write |
| 622 | + # only raises if the whole request was rejected. A partial failure |
| 623 | + # (e.g. a single-partition timeout) is otherwise silent, which in |
| 624 | + # an online-serving path presents downstream as "model saw stale |
| 625 | + # features" weeks after the fact. |
| 626 | + self._raise_on_batch_errors(batch.batch_records, set_name, op="write") |
| 627 | + written += len(chunk) |
| 628 | + if progress: |
| 629 | + progress(written) |
582 | 630 |
|
583 | 631 | # ------------------------------------------------------------------ |
584 | 632 | # Read path |
@@ -618,59 +666,64 @@ def online_read( |
618 | 666 | client = self._get_client(config) |
619 | 667 | ns = self._namespace_for_fv(config, table.name) |
620 | 668 | set_name = self._set_name(config, table.name) |
621 | | - |
622 | | - keys = [ |
623 | | - ( |
624 | | - ns, |
625 | | - set_name, |
626 | | - bytearray( |
627 | | - serialize_entity_key( |
628 | | - k, |
629 | | - entity_key_serialization_version=config.entity_key_serialization_version, |
630 | | - ) |
631 | | - ), |
632 | | - ) |
633 | | - for k in entity_keys |
634 | | - ] |
635 | 669 | read_ops = self._build_read_ops(table.name, requested_features) |
636 | | - |
637 | | - batch = client.batch_operate(keys, read_ops) |
| 670 | + chunk_size = self._batch_max_records(config) |
638 | 671 |
|
639 | 672 | # ``ids`` and ``docs`` use immutable ``bytes`` because ``bytearray`` is |
640 | 673 | # unhashable and can't key a dict. Keys on the wire must stay |
641 | 674 | # ``bytearray`` (see ``_aerospike_key``) — we only convert here for |
642 | 675 | # lookup. |
643 | | - ids = [bytes(user_key) for _, _, user_key in keys] |
| 676 | + ids: List[bytes] = [] |
644 | 677 | docs: Dict[bytes, Dict[str, Any]] = {} |
645 | | - # batch_operate preserves request order. We pair each response with |
646 | | - # the original user-key rather than ``br.key[2]``: the Aerospike |
647 | | - # client may return the key in a different representation (e.g. only |
648 | | - # the first byte as a str when the write didn't use POLICY_KEY_SEND |
649 | | - # for reads). |
650 | | - for user_key, br in zip(ids, batch.batch_records): |
651 | | - if br.result == _AS_ERR_RECORD_NOT_FOUND: |
652 | | - continue |
653 | | - if br.result == _AS_ERR_OP_NOT_APPLICABLE: |
654 | | - # The record exists but the nested feature-view slot doesn't; |
655 | | - # treat as a miss to match the OnlineStore contract. |
656 | | - continue |
657 | | - if br.result != _AS_OK: |
658 | | - raise RuntimeError( |
659 | | - f"Aerospike batch_operate returned a non-OK status for " |
660 | | - f"entity (ns={ns}, set={set_name}): result={br.result}" |
| 678 | + |
| 679 | + for entity_chunk in self._chunked(entity_keys, chunk_size): |
| 680 | + keys = [ |
| 681 | + ( |
| 682 | + ns, |
| 683 | + set_name, |
| 684 | + bytearray( |
| 685 | + serialize_entity_key( |
| 686 | + k, |
| 687 | + entity_key_serialization_version=config.entity_key_serialization_version, |
| 688 | + ) |
| 689 | + ), |
661 | 690 | ) |
662 | | - if br.record is None: |
663 | | - continue |
664 | | - _, _, bins = br.record |
665 | | - raw_features = bins.get("features") if bins else None |
666 | | - fv_event_ts_ms = bins.get("event_ts") if bins else None |
667 | | - fv_features = self._normalize_projected_features(raw_features) |
668 | | - docs[user_key] = { |
669 | | - "features": {table.name: fv_features} |
670 | | - if fv_features is not None |
671 | | - else {}, |
672 | | - "event_timestamps": {table.name: _epoch_ms_to_datetime(fv_event_ts_ms)}, |
673 | | - } |
| 691 | + for k in entity_chunk |
| 692 | + ] |
| 693 | + batch = client.batch_operate(keys, read_ops) |
| 694 | + chunk_ids = [bytes(user_key) for _, _, user_key in keys] |
| 695 | + ids.extend(chunk_ids) |
| 696 | + # batch_operate preserves request order. We pair each response with |
| 697 | + # the original user-key rather than ``br.key[2]``: the Aerospike |
| 698 | + # client may return the key in a different representation (e.g. only |
| 699 | + # the first byte as a str when the write didn't use POLICY_KEY_SEND |
| 700 | + # for reads). |
| 701 | + for user_key, br in zip(chunk_ids, batch.batch_records): |
| 702 | + if br.result == _AS_ERR_RECORD_NOT_FOUND: |
| 703 | + continue |
| 704 | + if br.result == _AS_ERR_OP_NOT_APPLICABLE: |
| 705 | + # The record exists but the nested feature-view slot doesn't; |
| 706 | + # treat as a miss to match the OnlineStore contract. |
| 707 | + continue |
| 708 | + if br.result != _AS_OK: |
| 709 | + raise RuntimeError( |
| 710 | + f"Aerospike batch_operate returned a non-OK status for " |
| 711 | + f"entity (ns={ns}, set={set_name}): result={br.result}" |
| 712 | + ) |
| 713 | + if br.record is None: |
| 714 | + continue |
| 715 | + _, _, bins = br.record |
| 716 | + raw_features = bins.get("features") if bins else None |
| 717 | + fv_event_ts_ms = bins.get("event_ts") if bins else None |
| 718 | + fv_features = self._normalize_projected_features(raw_features) |
| 719 | + docs[user_key] = { |
| 720 | + "features": {table.name: fv_features} |
| 721 | + if fv_features is not None |
| 722 | + else {}, |
| 723 | + "event_timestamps": { |
| 724 | + table.name: _epoch_ms_to_datetime(fv_event_ts_ms) |
| 725 | + }, |
| 726 | + } |
674 | 727 |
|
675 | 728 | return self._convert_raw_docs_to_proto(ids, docs, table) |
676 | 729 |
|
@@ -867,10 +920,11 @@ async def initialize(self, config: RepoConfig) -> None: |
867 | 920 |
|
868 | 921 | async def close(self) -> None: |
869 | 922 | """Release the cached Aerospike client, if any.""" |
870 | | - if self._client is None: |
871 | | - return |
872 | | - client = self._client |
873 | | - self._client = None |
| 923 | + with self._client_lock: |
| 924 | + if self._client is None: |
| 925 | + return |
| 926 | + client = self._client |
| 927 | + self._client = None |
874 | 928 | loop = asyncio.get_running_loop() |
875 | 929 | await loop.run_in_executor(None, client.close) |
876 | 930 |
|
@@ -987,6 +1041,7 @@ def teardown( |
987 | 1041 | # meaning. |
988 | 1042 | for ns, set_name in sorted(pairs): |
989 | 1043 | client.truncate(ns, set_name, 0) |
990 | | - if self._client is not None: |
991 | | - self._client.close() |
992 | | - self._client = None |
| 1044 | + with self._client_lock: |
| 1045 | + if self._client is not None: |
| 1046 | + self._client.close() |
| 1047 | + self._client = None |
0 commit comments