ic-python-db 0.14.0__tar.gz → 0.15.0__tar.gz
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-0.14.0/ic_python_db.egg-info → ic_python_db-0.15.0}/PKG-INFO +22 -1
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/README.md +21 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/__init__.py +1 -1
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/db_engine.py +444 -39
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/entity.py +36 -27
- {ic_python_db-0.14.0 → ic_python_db-0.15.0/ic_python_db.egg-info}/PKG-INFO +22 -1
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/pyproject.toml +1 -1
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/setup.py +1 -1
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/LICENSE +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/MANIFEST.in +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/_cdk.py +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/constants.py +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/context.py +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/errors.py +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/hooks.py +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/mixins.py +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/properties.py +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/py.typed +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/schema.py +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/storage.py +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db/system_time.py +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db.egg-info/SOURCES.txt +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db.egg-info/dependency_links.txt +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/ic_python_db.egg-info/top_level.txt +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/requirements-dev.txt +0 -0
- {ic_python_db-0.14.0 → ic_python_db-0.15.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ic-python-db
|
|
3
|
-
Version: 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:
|
|
@@ -183,6 +183,27 @@ The rebuild is idempotent. The `indexed` flag is not part of the schema descript
|
|
|
183
183
|
|
|
184
184
|
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`.
|
|
185
185
|
|
|
186
|
+
### Values that hold `|`
|
|
187
|
+
|
|
188
|
+
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`.
|
|
189
|
+
|
|
190
|
+
**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:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
db = Database.get_instance()
|
|
194
|
+
cursor = ""
|
|
195
|
+
while cursor is not None:
|
|
196
|
+
cursor = db.repair_field_indexes(cursor, limit=500) # keys read per call
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`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.
|
|
200
|
+
|
|
201
|
+
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.
|
|
202
|
+
|
|
203
|
+
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).
|
|
204
|
+
|
|
205
|
+
**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.
|
|
206
|
+
|
|
186
207
|
## Sorted Fields
|
|
187
208
|
|
|
188
209
|
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:
|
|
@@ -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.
|
|
25
|
+
__version__ = "0.15.0"
|
|
26
26
|
__all__ = [
|
|
27
27
|
"Database",
|
|
28
28
|
"Entity",
|
|
@@ -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.
|
|
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.
|
|
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(
|
|
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
|
|
660
|
+
lo = storage_sort_key(start)
|
|
661
|
+
hi = None if end is None else storage_sort_key(end)
|
|
561
662
|
keys = sorted(
|
|
562
|
-
(
|
|
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
|
-
|
|
572
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
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))
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]:
|
|
@@ -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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
1133
|
-
|
|
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.
|
|
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:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ic-python-db"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.15.0"
|
|
8
8
|
description = "A lightweight key-value database written in Python, intended for use on the Internet Computer (IC)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
|
|
5
5
|
|
|
6
6
|
setup(
|
|
7
7
|
name="ic-python-db",
|
|
8
|
-
version="0.
|
|
8
|
+
version="0.15.0",
|
|
9
9
|
author="Smart Social Contracts",
|
|
10
10
|
author_email="smartsocialcontracts@gmail.com",
|
|
11
11
|
description="A lightweight key-value database with entity relationships and audit logging",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|