ic-python-db 0.14.0__py3-none-any.whl → 0.15.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ic_python_db/__init__.py CHANGED
@@ -22,7 +22,7 @@ from .schema import SchemaIncompatibleError, build_schema, diff_schemas, schema_
22
22
  from .storage import MemoryStorage, Storage
23
23
  from .system_time import SystemTime
24
24
 
25
- __version__ = "0.14.0"
25
+ __version__ = "0.15.0"
26
26
  __all__ = [
27
27
  "Database",
28
28
  "Entity",
ic_python_db/db_engine.py CHANGED
@@ -22,6 +22,14 @@ _HEX = frozenset("0123456789abcdef")
22
22
  _ID_WIDTH = 12
23
23
  _HASH_WIDTH = 24
24
24
  _SCAN_PAGE = 500
25
+ # Field-index key families repair_field_indexes walks (see "Repairing field
26
+ # indexes")
27
+ _FI_FAMILIES = ("_fi:", "_fx:")
28
+ # Most ids of an old-form list the repair converts in one call: about 245
29
+ # thousand instructions an id, about 345 thousand where other old lists are
30
+ # among its entry keys (one more read an id), so 24.5 billion, or 34.5 billion
31
+ # at worst, in a call of 40 billion
32
+ _CONVERT_MAX_IDS = 100_000
25
33
  _BUCKETS = (("n", _ID_WIDTH, ":"), ("h", _HASH_WIDTH, "g"))
26
34
  # Purge queue of deleted index sets (see _ix_clear): fixed-width positions
27
35
  # keep its entries contiguous in key order
@@ -42,6 +50,72 @@ def _is_sequential_id(id_str: str) -> bool:
42
50
  return bool(id_str) and id_str[0] != "0" and all(c in _DIGITS for c in id_str)
43
51
 
44
52
 
53
+ def _id_digest(entity_id: str) -> str:
54
+ """The digest a posting-list entry of a custom id is keyed by."""
55
+ return hashlib.sha256(entity_id.encode("utf-8")).hexdigest()[:_HASH_WIDTH]
56
+
57
+
58
+ def _is_hash_entry_key(key: str) -> bool:
59
+ """True for a key that ends like the entry of a custom (hashed) id."""
60
+ digest = key[-_HASH_WIDTH:]
61
+ return key.endswith("|h" + digest) and all(c in _HEX for c in digest)
62
+
63
+
64
+ def _is_hash_entry(key: str, value: str) -> bool:
65
+ """True for the posting-list entry of a custom id: it is keyed by the digest
66
+ of its value. An old list stored under a value that ends like such a key
67
+ does not match it; only a value made for it does (``V|h`` and the digest of
68
+ the list's own JSON text), and that list then reads as an entry of ``V``."""
69
+ return _is_hash_entry_key(key) and _id_digest(value) == key[-_HASH_WIDTH:]
70
+
71
+
72
+ def _escape_key_text(text: str) -> str:
73
+ """``text`` with no ``|`` in it, and still told apart from every other text.
74
+
75
+ ``%`` is written ``%25`` and ``|`` is written ``%7C``. Escaping ``%`` too is
76
+ what keeps two texts from sharing a result (``a|`` and ``a%7C``).
77
+ """
78
+ return text.replace("%", "%25").replace("|", "%7C")
79
+
80
+
81
+ def _unescape_key_text(text: str) -> str:
82
+ """The text ``_escape_key_text`` was given."""
83
+ return text.replace("%7C", "|").replace("%25", "%")
84
+
85
+
86
+ def _entry_base(key: str) -> Optional[str]:
87
+ """The base a posting-list entry key belongs to; None for any other key
88
+ (a count, a mark, an old list)."""
89
+ tail = key[-_ID_WIDTH:]
90
+ if key.endswith("|n" + tail) and all(c in _DIGITS for c in tail):
91
+ return key[: -_ID_WIDTH - 2]
92
+ if _is_hash_entry_key(key):
93
+ return key[: -_HASH_WIDTH - 2]
94
+ return None
95
+
96
+
97
+ def _old_list_size(key: str, value: str) -> int:
98
+ """Ids in the list a release before 0.13 stored as one JSON array under
99
+ ``key``, a key of a value with a "|"; 0 for any other key or value. (The
100
+ entry of a custom id may read as an array: see ``_is_hash_entry``.)"""
101
+ if (
102
+ key.startswith("_fi:")
103
+ and "|" in key
104
+ and value.startswith("[")
105
+ and not _is_hash_entry(key, value)
106
+ ):
107
+ return value.count(",") + 1
108
+ return 0
109
+
110
+
111
+ def _listing_order(entity_id: str) -> Tuple[int, int]:
112
+ """Sort key of the order ``_ix_all`` lists ids in: sequential ids
113
+ ascending, then the others."""
114
+ if _is_sequential_id(entity_id) and len(entity_id) <= _ID_WIDTH:
115
+ return (0, int(entity_id))
116
+ return (1, 0)
117
+
118
+
45
119
  def _next_sequential_id(id_str: str, digits: int) -> Optional[int]:
46
120
  """Smallest ``digits``-digit sequential id that sorts after ``id_str``.
47
121
 
@@ -128,6 +202,9 @@ class Database:
128
202
  )
129
203
 
130
204
  self._entity_types = {}
205
+ # {target class name: reverse counter names}, built by the first
206
+ # delete after the registry last changed (see reverse_count_names)
207
+ self._reverse_count_names: Optional[Dict[str, List[str]]] = None
131
208
  # Identity map: {(type_name, entity_id): entity}. Weak, so an entity
132
209
  # lives only as long as some caller holds it, and its entry goes with it.
133
210
  self._entity_registry = weakref.WeakValueDictionary()
@@ -396,6 +473,16 @@ class Database:
396
473
  return type_name.split("::")[-1]
397
474
  return type_name
398
475
 
476
+ def _set_entity_type(self, name: str, type_obj) -> None:
477
+ """Point a registry name at a class, dropping what is derived from the registry.
478
+
479
+ Every Entity construction registers its class again, so an unchanged
480
+ entry must leave the derived data alone.
481
+ """
482
+ if self._entity_types.get(name) is not type_obj:
483
+ self._entity_types[name] = type_obj
484
+ self._reverse_count_names = None
485
+
399
486
  def register_entity_type(self, type_obj, type_name: str = None):
400
487
  """Register an entity type with the database.
401
488
 
@@ -410,7 +497,7 @@ class Database:
410
497
  )
411
498
 
412
499
  # Always register under the full type name
413
- self._entity_types[type_name] = type_obj
500
+ self._set_entity_type(type_name, type_obj)
414
501
 
415
502
  # For backward compatibility, also register under class name if:
416
503
  # 1. No namespace (type_name == class name) - allows non-namespaced lookups, OR
@@ -421,7 +508,7 @@ class Database:
421
508
  type_name == type_obj.__name__
422
509
  or type_obj.__name__ not in self._entity_types
423
510
  ):
424
- self._entity_types[type_obj.__name__] = type_obj
511
+ self._set_entity_type(type_obj.__name__, type_obj)
425
512
  else:
426
513
  # Class name already registered - log warning about potential collision
427
514
  existing = self._entity_types[type_obj.__name__]
@@ -550,16 +637,35 @@ class Database:
550
637
  # fixed-width suffixes keep one base's entries contiguous, and the "n"
551
638
  # entries come back in ascending numeric id order.
552
639
  #
640
+ # A base that holds a "|" can make keys of its own that look like another
641
+ # base's entries (base "a|n0000000000" has the key "a|n0000000000|c", as
642
+ # long as the entries "a|n000000000100"). A field index never has one (see
643
+ # _fi_key). A reverse index base, _ri:{Type}:{parent id}:{relation}, is
644
+ # not escaped, and a custom parent id may hold a "|": but the relation name
645
+ # ends the base, so a look-alike has to end with it as well, which is
646
+ # rarely possible. Either way a range read does not depend on it: it skips
647
+ # any key that is not an entry of its base and goes on (see _ix_bucket).
648
+ #
553
649
  # Before 0.13 a base key held the whole set as one JSON array, which hit
554
650
  # the value-size limit at ~10k ids. Such an array is converted to entries
555
651
  # the first time its base is touched (see _ix_count).
556
652
 
557
- def _scan(self, start: str, end: str, limit: int) -> List[Tuple[str, str]]:
653
+ def _scan(
654
+ self, start: str, end: Optional[str], limit: int
655
+ ) -> List[Tuple[str, str]]:
656
+ """Up to ``limit`` pairs with ``start <= key < end``, in key order;
657
+ ``end=None`` is no end."""
558
658
  if supports_range(self._db_storage):
559
659
  return self._db_storage.range(start, end, limit)
560
- lo, hi = storage_sort_key(start), storage_sort_key(end)
660
+ lo = storage_sort_key(start)
661
+ hi = None if end is None else storage_sort_key(end)
561
662
  keys = sorted(
562
- (k for k in self._db_storage.keys() if lo <= storage_sort_key(k) < hi),
663
+ (
664
+ k
665
+ for k in self._db_storage.keys()
666
+ if lo <= storage_sort_key(k)
667
+ and (hi is None or storage_sort_key(k) < hi)
668
+ ),
563
669
  key=storage_sort_key,
564
670
  )
565
671
  return [(k, self._db_storage.get(k)) for k in keys[:limit]]
@@ -568,16 +674,37 @@ class Database:
568
674
  def _ix_entry_key(base: str, entity_id: str) -> str:
569
675
  if _is_sequential_id(entity_id) and len(entity_id) <= _ID_WIDTH:
570
676
  return f"{base}|n{entity_id.zfill(_ID_WIDTH)}"
571
- digest = hashlib.sha256(entity_id.encode("utf-8")).hexdigest()
572
- return f"{base}|h{digest[:_HASH_WIDTH]}"
677
+ return f"{base}|h{_id_digest(entity_id)}"
678
+
679
+ def _ix_own(self, key: str) -> Optional[str]:
680
+ """What is stored under a key of this list's own (its count, its mark,
681
+ an entry of it), after an old list of another value that is stored
682
+ there is converted, which moves it out of the way.
683
+
684
+ Before 0.13 a list was one array under the bare key of its value, and
685
+ the bare key of the value "V|c" is the key of V's count; "V|d" is V's
686
+ mark and "V|n" and 12 digits an entry of V.
687
+ """
688
+ value = self._db_storage.get(key)
689
+ if value is not None and value.startswith("["):
690
+ # (An entry of a custom id that reads as an array is left: see
691
+ # _is_hash_entry)
692
+ self._ix_count(key)
693
+ value = self._db_storage.get(key)
694
+ return value
573
695
 
574
696
  def _ix_count(self, base: str) -> int:
575
- raw = self._db_storage.get(f"{base}|c")
697
+ raw = self._ix_own(f"{base}|c")
576
698
  if raw is not None:
577
699
  return int(raw)
578
700
  legacy = self._db_storage.get(base)
579
701
  if legacy is None:
580
702
  return 0
703
+ if _is_hash_entry(base, legacy):
704
+ # An entry holds a custom id, which may read as a list: it is not
705
+ # the array an old list was stored as, and converting it would
706
+ # remove the entry
707
+ return 0
581
708
  try:
582
709
  ids = json.loads(legacy)
583
710
  except ValueError:
@@ -586,8 +713,17 @@ class Database:
586
713
  return 0
587
714
  self._db_storage.remove(base)
588
715
  unique = {str(i) for i in ids}
716
+ # Old lists of other values among the entry keys (rare: two reads to
717
+ # know) are converted before an entry is written over them
718
+ crowded = any(
719
+ self._scan(f"{base}|{tag}{'0' * width}", f"{base}|{tag}{top * width}", 1)
720
+ for tag, width, top in _BUCKETS
721
+ )
589
722
  for entity_id in unique:
590
- self._db_storage.insert(self._ix_entry_key(base, entity_id), entity_id)
723
+ key = self._ix_entry_key(base, entity_id)
724
+ if crowded:
725
+ self._ix_own(key)
726
+ self._db_storage.insert(key, entity_id)
591
727
  if unique:
592
728
  self._db_storage.insert(f"{base}|c", str(len(unique)))
593
729
  return len(unique)
@@ -598,7 +734,7 @@ class Database:
598
734
  if count == 0:
599
735
  self._ix_revive(base)
600
736
  key = self._ix_entry_key(base, entity_id)
601
- if self._db_storage.get(key) is not None:
737
+ if self._ix_own(key) == entity_id:
602
738
  return
603
739
  self._db_storage.insert(key, entity_id)
604
740
  self._db_storage.insert(f"{base}|c", str(count + 1))
@@ -609,8 +745,8 @@ class Database:
609
745
  if count == 0:
610
746
  return
611
747
  key = self._ix_entry_key(base, entity_id)
612
- if self._db_storage.get(key) is None:
613
- return
748
+ if self._db_storage.get(key) != entity_id:
749
+ return # not listed (the key may be an old list of another value)
614
750
  self._db_storage.remove(key)
615
751
  if count > 1:
616
752
  self._db_storage.insert(f"{base}|c", str(count - 1))
@@ -620,7 +756,7 @@ class Database:
620
756
  def _ix_contains(self, base: str, entity_id: str) -> bool:
621
757
  if self._ix_count(base) == 0:
622
758
  return False
623
- return self._db_storage.get(self._ix_entry_key(base, entity_id)) is not None
759
+ return self._db_storage.get(self._ix_entry_key(base, entity_id)) == entity_id
624
760
 
625
761
  def _ix_page(self, base: str, from_id: int, count: int) -> List[str]:
626
762
  """Up to ``count`` sequential ids >= ``from_id``, ascending."""
@@ -630,35 +766,55 @@ class Database:
630
766
  raise ValueError("count must be at least 1")
631
767
  if self._ix_count(base) == 0 or len(str(from_id)) > _ID_WIDTH:
632
768
  return []
633
- start = f"{base}|n{str(from_id).zfill(_ID_WIDTH)}"
634
- end = f"{base}|n{':' * _ID_WIDTH}"
635
- cut = len(base) + 2
636
- # A key of another base can fall inside the range only if its suffix
637
- # is not all digits (e.g. value "x|n0;..." next to value "x").
638
- return [
639
- value
640
- for key, value in self._scan(start, end, count)
641
- if all(c in _DIGITS for c in key[cut:])
642
- ]
769
+ ids: List[str] = []
770
+ first = str(from_id).zfill(_ID_WIDTH)
771
+ for _, value in self._ix_bucket(base, "n", _ID_WIDTH, ":", count, first):
772
+ ids.append(value)
773
+ if len(ids) == count:
774
+ break
775
+ return ids
643
776
 
644
777
  def _ix_bucket(
645
- self, base: str, tag: str, width: int, top: str, page_size: int = _SCAN_PAGE
778
+ self,
779
+ base: str,
780
+ tag: str,
781
+ width: int,
782
+ top: str,
783
+ page_size: int = _SCAN_PAGE,
784
+ first: Optional[str] = None,
646
785
  ):
647
- start = f"{base}|{tag}{'0' * width}"
786
+ """The entries of ``base`` under ``tag`` (from the entry suffix
787
+ ``first``, when given), in key order.
788
+
789
+ A key of another base can fall in the range (see "Posting lists"), and
790
+ so can the old list of another value (see _ix_own): neither is an entry,
791
+ which holds the id its key spells. A page that came back short is the
792
+ end of the range; one that is full but held such keys is not, so the
793
+ scan goes on from its last key.
794
+ """
795
+ start = f"{base}|{tag}{first or '0' * width}"
648
796
  end = f"{base}|{tag}{top * width}"
649
797
  cut = len(base) + 2
650
798
  valid = _DIGITS if tag == "n" else _HEX
799
+ last = None
651
800
  while True:
652
- page = self._scan(start, end, page_size)
801
+ # A range starts at its last key, which is read twice: one more
802
+ # than a page. (No "next key" is computed: the successor of a
803
+ # char can be longer in utf-8, so the key after it is not the
804
+ # same length.)
805
+ wanted = page_size if last is None else page_size + 1
806
+ page = self._scan(start, end, wanted)
653
807
  for key, value in page:
654
- if all(c in valid for c in key[cut:]):
655
- yield key, value
656
- if len(page) < page_size:
808
+ if key != last and all(c in valid for c in key[cut:]):
809
+ if tag == "n":
810
+ own = value == str(int(key[cut:]))
811
+ else:
812
+ own = not value.startswith("[") or _is_hash_entry(key, value)
813
+ if own:
814
+ yield key, value
815
+ if len(page) < wanted:
657
816
  return
658
- # Smallest same-length key after the last one: append the lowest
659
- # char and drop the last, i.e. bump by one in the key order.
660
- last = page[-1][0]
661
- start = last[:-1] + chr(ord(last[-1]) + 1)
817
+ last = start = page[-1][0]
662
818
 
663
819
  def _ix_all(self, base: str) -> List[str]:
664
820
  """Every id in the set: sequential ids ascending, then the others."""
@@ -710,7 +866,7 @@ class Database:
710
866
 
711
867
  def _ix_revive(self, base: str) -> None:
712
868
  """Before reusing a deleted set: purge its leftovers, drop its queue entry."""
713
- position = self._db_storage.get(f"{base}|d")
869
+ position = self._ix_own(f"{base}|d")
714
870
  if position is None:
715
871
  return
716
872
  while self._ix_drain(base, _SCAN_PAGE) == _SCAN_PAGE:
@@ -828,38 +984,247 @@ class Database:
828
984
  # Allow equality lookups (Entity.find_by) without scanning all entities.
829
985
  #
830
986
  # Base key: _fi:{Type}:{field}:{value} (posting list)
987
+ #
988
+ # A value is text a caller chooses, so its base must not hold a "|" (see
989
+ # "Posting lists"). One that holds a "|" goes under _fx instead, with "|"
990
+ # and "%" written as %7C and %25 (so that no two values share an escaped
991
+ # form): _fx:{Type}:{field}:{escaped value}. Every other value, "%" alone
992
+ # included, keeps the key it always had, so nothing moved for them.
993
+ #
994
+ # Such a value used to be filed under _fi with no escape. Its entries stay
995
+ # readable there: each call below also looks at the old key of a value that
996
+ # has one, and adding or removing an id takes it off the old list.
997
+ # repair_field_indexes moves them all, those of custom ids too (which
998
+ # Entity.rebuild_field_indexes does not reach), and converts the lists
999
+ # stored before 0.13 as one array. Once it has run to its end on every
1000
+ # realm, _fi_old_key and what uses it can go, but for what it cannot judge
1001
+ # and leaves (see its docstring). (_fx, not _fi, so that an old key can
1002
+ # never be taken for the new key of another value.)
1003
+
1004
+ @staticmethod
1005
+ def _fi_needs_escape(value: str) -> bool:
1006
+ return "|" in value
831
1007
 
832
1008
  def _fi_key(self, type_name: str, field_name: str, value: str) -> str:
1009
+ if self._fi_needs_escape(value):
1010
+ return f"_fx:{type_name}:{field_name}:{_escape_key_text(value)}"
833
1011
  return f"_fi:{type_name}:{field_name}:{value}"
834
1012
 
1013
+ def _fi_old_key(self, type_name: str, field_name: str, value: str) -> Optional[str]:
1014
+ """Where ``value`` was filed before it was escaped, if not where it is now."""
1015
+ if self._fi_needs_escape(value):
1016
+ return f"_fi:{type_name}:{field_name}:{value}"
1017
+ return None
1018
+
835
1019
  def field_index_get(self, type_name: str, field_name: str, value: str) -> List[str]:
836
1020
  """All ids of entities whose ``field_name`` equals ``value``
837
1021
  (O(matches); prefer ``field_index_page``)."""
838
- return self._ix_all(self._fi_key(type_name, field_name, value))
1022
+ ids = self._ix_all(self._fi_key(type_name, field_name, value))
1023
+ old = self._fi_old_key(type_name, field_name, value)
1024
+ if old is None:
1025
+ return ids
1026
+ # An id on both lists (a release that did not know the new keys wrote
1027
+ # to the old one) is listed once
1028
+ ids = list(dict.fromkeys(ids + self._ix_all(old)))
1029
+ return sorted(ids, key=_listing_order)
839
1030
 
840
1031
  def field_index_page(
841
1032
  self, type_name: str, field_name: str, value: str, from_id: int, count: int
842
1033
  ) -> List[str]:
843
1034
  """Up to ``count`` sequential ids >= ``from_id`` whose ``field_name``
844
- equals ``value``, ascending. One range read."""
845
- return self._ix_page(self._fi_key(type_name, field_name, value), from_id, count)
1035
+ equals ``value``, ascending. One range read; two for a value with a
1036
+ "|" while its old key may hold entries, and one more for each key of
1037
+ another value in the way."""
1038
+ key = self._fi_key(type_name, field_name, value)
1039
+ ids = self._ix_page(key, from_id, count)
1040
+ old = self._fi_old_key(type_name, field_name, value)
1041
+ if old is None:
1042
+ return ids
1043
+ # The first ``count`` of both lists together are among the first
1044
+ # ``count`` of each
1045
+ ids += self._ix_page(old, from_id, count)
1046
+ return sorted(set(ids), key=int)[:count]
846
1047
 
847
1048
  def field_index_count(self, type_name: str, field_name: str, value: str) -> int:
848
- """Number of entities whose ``field_name`` equals ``value`` (one read)."""
849
- return self._ix_count(self._fi_key(type_name, field_name, value))
1049
+ """Number of entities whose ``field_name`` equals ``value`` (one read;
1050
+ two for a value with a "|" while its old key may hold entries, and an id
1051
+ on both lists is counted twice until repair_field_indexes has run)."""
1052
+ total = self._ix_count(self._fi_key(type_name, field_name, value))
1053
+ old = self._fi_old_key(type_name, field_name, value)
1054
+ if old is not None:
1055
+ total += self._ix_count(old)
1056
+ return total
850
1057
 
851
1058
  def field_index_add(
852
1059
  self, type_name: str, field_name: str, value: str, entity_id: str
853
1060
  ) -> None:
854
1061
  """Add an entity ID to the index entry for ``value``."""
1062
+ old = self._fi_old_key(type_name, field_name, value)
1063
+ if old is not None:
1064
+ self._ix_remove(old, entity_id)
855
1065
  self._ix_add(self._fi_key(type_name, field_name, value), entity_id)
856
1066
 
857
1067
  def field_index_remove(
858
1068
  self, type_name: str, field_name: str, value: str, entity_id: str
859
1069
  ) -> None:
860
1070
  """Remove an entity ID from the index entry for ``value``."""
1071
+ old = self._fi_old_key(type_name, field_name, value)
1072
+ if old is not None:
1073
+ self._ix_remove(old, entity_id)
861
1074
  self._ix_remove(self._fi_key(type_name, field_name, value), entity_id)
862
1075
 
1076
+ # ── Repairing field indexes ────────────────────────────────────────────────
1077
+ #
1078
+ # A release that does not know the _fx keys files a value with a "|" under
1079
+ # its _fi key, and does not take an id off the _fx list when the entity's
1080
+ # value changes or the entity is deleted. After an upgrade from it (or
1081
+ # after a rollback to it and a new upgrade) entries are under the old key,
1082
+ # or stale on a list, or on both; a list stored before 0.13 may still be
1083
+ # one JSON array. Entity.rebuild_field_indexes walks the entities, so it
1084
+ # can add or move an entry, but it never finds a stale one and does not
1085
+ # reach custom ids. This walks the keys instead, and checks each entry of a
1086
+ # value with a "|" against its entity.
1087
+
1088
+ def _index_ranges(self, after: str):
1089
+ """The (start, end) key ranges that hold field-index keys, in the order
1090
+ repair_field_indexes walks them, from the one that holds ``after``.
1091
+
1092
+ Storage orders keys by length first, so each length of each family is a
1093
+ range of its own. Only lengths at which the map holds a key are
1094
+ visited (the shortest key at least as long as the last length tells
1095
+ the next), so no length is out of reach.
1096
+ """
1097
+ resume = after[:4] if after else ""
1098
+ first = _FI_FAMILIES.index(resume) if resume else 0
1099
+ for family in _FI_FAMILIES[first:]:
1100
+ length = len(family) + 1
1101
+ if family == resume:
1102
+ length = len(after.encode("utf-8"))
1103
+ while True:
1104
+ shortest = self._scan("\x00" * length, None, 1)
1105
+ if not shortest:
1106
+ break
1107
+ length = len(shortest[0][0].encode("utf-8"))
1108
+ fill = "\x00" * (length - len(family))
1109
+ resumed = family == resume and length == len(after.encode("utf-8"))
1110
+ yield (after if resumed else family + fill), (
1111
+ family[:-1] + chr(ord(family[-1]) + 1) + fill
1112
+ )
1113
+ length += 1
1114
+
1115
+ def _field_of_base(self, rest: str) -> Optional[Tuple[str, str, str]]:
1116
+ """(type name, field, text) of ``{Type}:{field}:{text}``, when the type
1117
+ is registered and the field indexed; None otherwise."""
1118
+ for entity_class in self._entity_types.values():
1119
+ type_name = entity_class.get_full_type_name()
1120
+ if not rest.startswith(type_name + ":"):
1121
+ continue
1122
+ skip = len(type_name) + 1
1123
+ field, colon, text = rest[skip:].partition(":")
1124
+ if colon and field in entity_class._indexed_properties():
1125
+ return type_name, field, text
1126
+ return None
1127
+
1128
+ def _repair_entry(self, key: str, value: str) -> None:
1129
+ size = _old_list_size(key, value)
1130
+ if size:
1131
+ if size <= _CONVERT_MAX_IDS:
1132
+ # Converted into entries, which are longer keys: the walk
1133
+ # reaches them later
1134
+ self._ix_count(key)
1135
+ else:
1136
+ logger.warning(
1137
+ f"repair_field_indexes: the old list {key!r} has {size} ids, "
1138
+ f"more than a call can convert; left as it is"
1139
+ )
1140
+ return
1141
+ base = _entry_base(key)
1142
+ if base is None:
1143
+ return
1144
+ rest, old_style = base[4:], key.startswith("_fi:")
1145
+ if old_style and "|" not in rest:
1146
+ return # a value without "|": its key never changed
1147
+ found = self._field_of_base(rest)
1148
+ if found is None:
1149
+ return # not a type and field this code can judge
1150
+ type_name, field, text = found
1151
+ text = text if old_style else _unescape_key_text(text)
1152
+ row = self.load(type_name, value) # an entry's value is its entity id
1153
+ if row is not None and field not in row:
1154
+ return # a row from before the field: nothing to compare with
1155
+ if row is None or row[field] is None or str(row[field]) != text:
1156
+ self._ix_remove(base, value)
1157
+ elif old_style:
1158
+ self.field_index_add(type_name, field, text, value)
1159
+
1160
+ def repair_field_indexes(self, after: str = "", limit: int = 500) -> Optional[str]:
1161
+ """Check the field-index entries of values with a ``|`` against the
1162
+ entities, at most ``limit`` keys at a time; resumable.
1163
+
1164
+ An entry whose entity is gone, or no longer has that value, is removed.
1165
+ One under the old key (before escaping) whose entity has the value is
1166
+ moved to its present key, so an id is listed once. A list a release
1167
+ before 0.13 stored as one array under the old key of such a value is
1168
+ converted into entries first, and they are checked later in the walk.
1169
+ Entries of values without a ``|`` are only looked at.
1170
+
1171
+ cursor = ""
1172
+ while cursor is not None:
1173
+ cursor = Database.get_instance().repair_field_indexes(cursor)
1174
+
1175
+ Run it to its end after upgrading from a release before escaping, and
1176
+ again after a rollback to one and a new upgrade, **from ``after=""``**:
1177
+ a cursor, or a note that it was done, saved before the rollback skips
1178
+ what the older release wrote. Nothing else needs to run first. Once it
1179
+ has run to its end, no old key holds an entry and counts are exact,
1180
+ except for what it cannot judge and leaves as it is: entries of a type
1181
+ or field this code does not index; entries whose row has no such field;
1182
+ an old-form list of more than 100 thousand ids (it is logged; a read of
1183
+ its value converts it where it fits the message, about 160 thousand ids
1184
+ in an update and 20 thousand in a query, and traps where it does not,
1185
+ as before); and an old-form list of a value made to end with ``|h`` and
1186
+ the digest of its own text, which reads as an entry of the value it
1187
+ extends (only such a value matches; ``find_by`` is not affected). A
1188
+ chain of old lists of ``V``, ``V|c``, ``V|c|c`` and so on deeper than
1189
+ about 500 raises ``RecursionError``: also a value made for it.
1190
+
1191
+ Cost, measured on the IC: a key read about 0.1 million instructions; an
1192
+ entry of a value with a ``|`` that is right about 0.73 million (its row
1193
+ is loaded); an old entry moved about 2 million, so a call of 500 moves
1194
+ is about 1 billion; and about 0.58 million for each distinct key length
1195
+ in the whole map that a call walks past (only the lengths at which the
1196
+ map holds a key are visited). A call is O(``limit`` + those lengths). A
1197
+ list it converts counts its ids against ``limit``, and one longer than
1198
+ ``limit`` is converted whole, alone in its call, at about 245 thousand
1199
+ instructions an id: a map with limited value size holds at most about
1200
+ 10 thousand ids in a list, but a realm's is not limited (a list of
1201
+ 127,000 ids is on record: about 31 billion; it is over the cap, so the
1202
+ repair leaves it, and a read of its value in an update converts it).
1203
+
1204
+ Returns:
1205
+ The ``after`` of the call that continues, None when the walk is done.
1206
+ """
1207
+ if limit < 1:
1208
+ raise ValueError("limit must be at least 1")
1209
+ if after and after[:4] not in _FI_FAMILIES:
1210
+ raise ValueError("after is not a cursor of repair_field_indexes")
1211
+ seen, last = 0, after
1212
+ for start, end in self._index_ranges(after):
1213
+ # One more than is left: a range starts at its last key. A range
1214
+ # with more keys than that fills the call, and ends it below
1215
+ wanted = limit - seen + 1
1216
+ for key, value in self._scan(start, end, wanted):
1217
+ if key == last:
1218
+ continue
1219
+ cost = _old_list_size(key, value) or 1
1220
+ if seen and seen + cost > limit:
1221
+ return last
1222
+ self._repair_entry(key, value)
1223
+ last, seen = key, seen + cost
1224
+ if seen >= limit:
1225
+ return last
1226
+ return None
1227
+
863
1228
  # ── Sorted indexes ─────────────────────────────────────────────────────────
864
1229
  #
865
1230
  # A sorted index keeps one entry per entity whose key starts with the
@@ -988,6 +1353,46 @@ class Database:
988
1353
  if self._db_storage.get(key) is not None:
989
1354
  self._db_storage.remove(key)
990
1355
 
1356
+ def reverse_count_names(self, type_name: str) -> List[str]:
1357
+ """Names of the reverse counters that entities of a type carry.
1358
+
1359
+ These are the ``reverse_name`` of every unidirectional ManyToMany that
1360
+ a registered class declares towards ``type_name``; a deleted entity
1361
+ must delete its counters. Finding them reads every attribute of every
1362
+ registered class, which costs millions of IC instructions with a
1363
+ realm's 93 types, so the answer is kept until ``register_entity_type``
1364
+ changes the registry.
1365
+ """
1366
+ if self._reverse_count_names is None:
1367
+ self._reverse_count_names = self._find_reverse_count_names()
1368
+ return self._reverse_count_names.get(self._extract_class_name(type_name), [])
1369
+
1370
+ def _find_reverse_count_names(self) -> Dict[str, List[str]]:
1371
+ """{target class name: reverse counter names}, from the registered classes."""
1372
+ from .properties import ManyToMany
1373
+
1374
+ names: Dict[str, List[str]] = {}
1375
+ read = set() # a base class is shared by many registered classes
1376
+ for entity_class in self._entity_types.values():
1377
+ if not isinstance(entity_class, type):
1378
+ continue
1379
+ for cls in entity_class.__mro__:
1380
+ if cls in read:
1381
+ continue
1382
+ read.add(cls)
1383
+ for attr_name, attr in cls.__dict__.items():
1384
+ if attr_name.startswith("_"):
1385
+ continue
1386
+ if not isinstance(attr, ManyToMany) or not attr.unidirectional:
1387
+ continue
1388
+ for target in attr._get_allowed_types():
1389
+ reverse_names = names.setdefault(
1390
+ self._extract_class_name(target), []
1391
+ )
1392
+ if attr.reverse_name not in reverse_names:
1393
+ reverse_names.append(attr.reverse_name)
1394
+ return names
1395
+
991
1396
  def get_audit(
992
1397
  self, id_from: Optional[int] = None, id_to: Optional[int] = None
993
1398
  ) -> Dict[str, str]:
ic_python_db/entity.py CHANGED
@@ -657,7 +657,9 @@ class Entity:
657
657
  while cursor is not None:
658
658
  cursor = Proposal.rebuild_field_index("org_scope", from_id=cursor)
659
659
 
660
- Idempotent — re-indexing an already-indexed entity is a no-op.
660
+ Idempotent — re-indexing an already-indexed entity is a no-op. An
661
+ entity whose value holds ``|`` and was indexed under the key such a
662
+ value had before it was escaped is moved to its present key.
661
663
 
662
664
  Returns:
663
665
  The from_id for the next batch, or None when the rebuild is done.
@@ -666,6 +668,33 @@ class Entity:
666
668
  ValueError: If the field is not declared with indexed=True
667
669
  """
668
670
  cls._require_indexed_field(field)
671
+ return cls._rebuild_field_indexes([field], from_id, batch)
672
+
673
+ @classmethod
674
+ def rebuild_field_indexes(
675
+ cls: Type[T],
676
+ from_id: int = 1,
677
+ batch: int = 200,
678
+ ) -> Optional[int]:
679
+ """``rebuild_field_index`` for every indexed field of the type at once.
680
+
681
+ Moves the entries of values holding ``|`` that an earlier version filed
682
+ under their own text, for entities with sequential ids: until they are
683
+ moved they are still found, under the old key. It adds an entry that is
684
+ missing but never removes one, and does not reach custom ids:
685
+ ``Database.repair_field_indexes`` does both, and is what completes the
686
+ move after an upgrade.
687
+ Same cursor and batch as ``rebuild_field_index``; one batch costs the
688
+ entities it loads once, and a write per indexed field of each.
689
+ """
690
+ return cls._rebuild_field_indexes(
691
+ list(cls._indexed_properties()), from_id, batch
692
+ )
693
+
694
+ @classmethod
695
+ def _rebuild_field_indexes(
696
+ cls: Type[T], fields: List[str], from_id: int, batch: int
697
+ ) -> Optional[int]:
669
698
  if from_id < 1:
670
699
  raise ValueError("from_id must be at least 1")
671
700
  if batch < 1:
@@ -680,9 +709,10 @@ class Entity:
680
709
  # One range read per batch of *live* entities (deleted ids cost nothing)
681
710
  entities = cls.load_some(from_id, batch)
682
711
  for entity in entities:
683
- value = getattr(entity, field, None)
684
- if value is not None:
685
- db.field_index_add(type_name, field, str(value), entity._id)
712
+ for field in fields:
713
+ value = getattr(entity, field, None)
714
+ if value is not None:
715
+ db.field_index_add(type_name, field, str(value), entity._id)
686
716
 
687
717
  if len(entities) < batch:
688
718
  return None
@@ -1129,29 +1159,8 @@ class Entity:
1129
1159
  # via unidirectional ManyToMany relations targeting our type. Their
1130
1160
  # forward indexes may retain a dangling ID (skipped on load); the
1131
1161
  # counter itself must not outlive us.
1132
- seen_classes = set()
1133
- for entity_class in db._entity_types.values():
1134
- if entity_class in seen_classes or not isinstance(entity_class, type):
1135
- continue
1136
- seen_classes.add(entity_class)
1137
- for cls in reversed(entity_class.__mro__):
1138
- for attr_name, attr_value in cls.__dict__.items():
1139
- if attr_name.startswith("_"):
1140
- continue
1141
- if isinstance(attr_value, ManyToMany) and attr_value.unidirectional:
1142
- my_short = (
1143
- self._type.split("::")[-1]
1144
- if "::" in self._type
1145
- else self._type
1146
- )
1147
- targets = [
1148
- t.split("::")[-1] if "::" in t else t
1149
- for t in attr_value._get_allowed_types()
1150
- ]
1151
- if my_short in targets:
1152
- db.reverse_count_delete(
1153
- self._type, self._id, attr_value.reverse_name
1154
- )
1162
+ for reverse_name in db.reverse_count_names(self._type):
1163
+ db.reverse_count_delete(self._type, self._id, reverse_name)
1155
1164
 
1156
1165
  # Remove from field indexes (issue #11)
1157
1166
  for field_name in self._indexed_properties():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ic-python-db
3
- Version: 0.14.0
3
+ Version: 0.15.0
4
4
  Summary: A lightweight key-value database written in Python, intended for use on the Internet Computer (IC)
5
5
  Home-page: https://github.com/smart-social-contracts/ic-python-db
6
6
  Author: Smart Social Contracts
@@ -232,6 +232,27 @@ The rebuild is idempotent. The `indexed` flag is not part of the schema descript
232
232
 
233
233
  Each matching id is its own storage entry, so a value can match any number of entities without reaching the backing store's `max_value_size`.
234
234
 
235
+ ### Values that hold `|`
236
+
237
+ An index entry is a key that starts with the value, then `|`, a letter and the id (`_fi:Ticket:status:open|n000000000042`). A value is text a caller chooses, and one that ended like such a key would put keys of its own among the entries of another value, where a page read could miss some of them. So a value with a `|` in it is escaped (`|` as `%7C`, and `%` as `%25`, so that two values never share an escaped form) and filed under `_fx` instead of `_fi`: no value can hold a `|`, so the keys of two values never meet. Every other value keeps the key it always had, one with only a `%` in it included. The escaped key is longer (two more characters for each `|` or `%`), which counts against the storage map's `max_key_size`.
238
+
239
+ **Upgrading from 0.14.** Entries of values with a `|`, filed before this under `_fi` with the value as it was, stay readable: `find_by`, `count_by` and `field_index_*` also read the old key of such a value, and adding or removing an id takes it off the old list. If 0.13 or 0.14 already ran on a store of 0.12 that holds the old lists of both a value `V` and `V|n` and 12 digits, or `V|h` and the digest of a custom id in `V`'s list, converting `V` wrote its entry over the other value's old list: that value's ids are lost. `Entity.rebuild_field_indexes` re-adds the sequential ones (run it, then the repair); a custom id comes back only when its entity's field is next changed, or when `Database.field_index_add` is called for it. To move them all, run once after upgrading, in bounded calls:
240
+
241
+ ```python
242
+ db = Database.get_instance()
243
+ cursor = ""
244
+ while cursor is not None:
245
+ cursor = db.repair_field_indexes(cursor, limit=500) # keys read per call
246
+ ```
247
+
248
+ `repair_field_indexes` checks each entry of a value with a `|` against its entity: it moves an old entry to its present key and removes one whose entity is gone or has another value. It reaches custom ids and keys of any length, and it converts a list that a release before 0.13 stored as one array under the old key of such a value (a list counts its ids against `limit`; one longer than `limit` is converted whole, alone in its call). Nothing needs to run before it. `Entity.rebuild_field_indexes(from_id, batch)` does the move per type for sequential ids only, and adds entries that are missing; it never removes one.
249
+
250
+ When the repair has run to its end no old key holds an entry and counts are exact, except for what it cannot judge and leaves as it is: entries of a type or field the code does not index; entries whose row has no such field; an old-form list of more than 100 thousand ids (it is logged; a read of its value converts it where it fits the message, about 160 thousand ids in an update and 20 thousand in a query, and traps where it does not, as before); and an old-form list of a value made to end with `|h` and the digest of its own text, which reads as an entry of the value it extends (only such a value matches; `find_by` is not affected). A chain of old lists of `V`, `V|c`, `V|c|c` and so on deeper than about 500 raises `RecursionError` (also a value made for it). Only then may a release drop the old-key reads.
251
+
252
+ Cost, measured on the IC: a key read about 0.1 million instructions; an entry of a value with a `|` that is right about 0.73 million (its row is loaded); an old entry moved about 2 million, so a call of 500 moves is about 1 billion; and about 0.58 million for each distinct key length in the whole map that a call walks past. A call is O(`limit` + those lengths). An old-form list is converted whole at about 245 thousand instructions an id: a map with limited value size holds at most about 10 thousand ids in a list, but a realm's is not limited (a list of 127,000 ids is on record: about 31 billion instructions; it is over the cap of 100 thousand ids, so the repair leaves it, and a read of its value in an update converts it).
253
+
254
+ **Rolling back to a release before this one, and upgrading again.** While the older release runs, it files values with a `|` under `_fi` (this release reads them there), but it does not know `_fx`: entries filed under it are invisible to it, and it does not remove one when an entity's value changes or the entity is deleted. After the next upgrade a list can hold an entity that no longer has the value, or an id on both lists, so `count_by` and `find_by` can be wrong for those values. Run `repair_field_indexes` to its end again, **from `after=""`**: a cursor, or a note that it was done, saved before the rollback skips what the older release wrote. It puts it right.
255
+
235
256
  ## Sorted Fields
236
257
 
237
258
  Declare `sorted=True` on an `Integer` or `Float` property to read entities in its order, highest or lowest first, without loading and sorting them all. `__sorted_indexes__` adds indexes over several fields, where each field breaks ties in the one before:
@@ -1,9 +1,9 @@
1
- ic_python_db/__init__.py,sha256=6qHPpgma0wqCnnOVrfT_Wp3KEFHy6Q20XFS22_-kh6o,1083
1
+ ic_python_db/__init__.py,sha256=B4YsbiquQKnGlWpxE0Kfc8Q4dj2gwdaL5NHXBldcSQM,1083
2
2
  ic_python_db/_cdk.py,sha256=zTj8HzXUcPz8R73nh5bo6JMkCorp-KSW1hDutzi5LZs,387
3
3
  ic_python_db/constants.py,sha256=gs6klyqUxUjE0LFJxXpaya8QEgYeKx4ovP9Mu04rtHk,351
4
4
  ic_python_db/context.py,sha256=97F4_EAJm3adOnUfh0SaDisX3nc7F2vFIZwZUsUAy58,1080
5
- ic_python_db/db_engine.py,sha256=v6jjC4wcjBOpXRNjw62gV9RC3Own795joLp00l3GUfM,40661
6
- ic_python_db/entity.py,sha256=zXru0y1eGRImT1vdIMA-l8fcGvOAWHyFps4XwuyuOlQ,63458
5
+ ic_python_db/db_engine.py,sha256=1Sl6mDv86AdOL9j2zp6TAG89gjI6XbGLZLttmKsOsak,60401
6
+ ic_python_db/entity.py,sha256=wlCpRLcqK2FYosPgm3YtXReR6ygZigQkzO78twQqdMc,63768
7
7
  ic_python_db/errors.py,sha256=q_2oAu2mX4B3eGySS-8xOU_CIoKoPlxKwECkbxaHQ1E,366
8
8
  ic_python_db/hooks.py,sha256=B1wV18dAMH-PootSripmMpG9Cr9M3W3lcNEF7QvPrL4,1620
9
9
  ic_python_db/mixins.py,sha256=qZmb3ssXYf2NSjHJtgmdsOlkIJdouDjPHdQ9tZmrZi8,1937
@@ -12,8 +12,8 @@ ic_python_db/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  ic_python_db/schema.py,sha256=h1RAbapExo8pMD5IQvK8ZTVkgyonbPV9qSqliSP0Y14,16249
13
13
  ic_python_db/storage.py,sha256=899qyuA3alpAFIPM23A_07vqquUB5ZG5GjjpzlHW1Os,3828
14
14
  ic_python_db/system_time.py,sha256=xrx8NfjLXCGsac3hUpuvsNwCH91tQ9jfH4N-XFo8iJk,2614
15
- ic_python_db-0.14.0.dist-info/licenses/LICENSE,sha256=6q6XYNOGnJcVSus2bAezFn7bU_2Y5T6W4aGQHBb8X-c,1079
16
- ic_python_db-0.14.0.dist-info/METADATA,sha256=-0meDeN9zfnSvm0mGyRihstBVAkpuQj1AjAHOgZqG_0,20070
17
- ic_python_db-0.14.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
18
- ic_python_db-0.14.0.dist-info/top_level.txt,sha256=ZaOTqhWKtJQEuXmqR920AqbDBAqv1mvsKj3TClWimDk,13
19
- ic_python_db-0.14.0.dist-info/RECORD,,
15
+ ic_python_db-0.15.0.dist-info/licenses/LICENSE,sha256=6q6XYNOGnJcVSus2bAezFn7bU_2Y5T6W4aGQHBb8X-c,1079
16
+ ic_python_db-0.15.0.dist-info/METADATA,sha256=paSQqVYU1bhS_IB-jr6exDYqdoOt_xXSx9uKdmemBjs,24628
17
+ ic_python_db-0.15.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
18
+ ic_python_db-0.15.0.dist-info/top_level.txt,sha256=ZaOTqhWKtJQEuXmqR920AqbDBAqv1mvsKj3TClWimDk,13
19
+ ic_python_db-0.15.0.dist-info/RECORD,,