From 0a18daa676ad4b861738df5046a3243cc3d0bd0c Mon Sep 17 00:00:00 2001 From: ryan-hansen Date: Tue, 31 Mar 2026 00:51:08 -0600 Subject: [PATCH 1/2] Add delta override --- src/keri/core/kraming.py | 24 +++++++- src/keri/kering.py | 2 +- tests/core/test_kraming.py | 115 +++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/src/keri/core/kraming.py b/src/keri/core/kraming.py index de7cd0d34..c47b49254 100644 --- a/src/keri/core/kraming.py +++ b/src/keri/core/kraming.py @@ -1205,7 +1205,7 @@ def kramit(self, msg, **kwa): else: raise KramError("Unexpected auth type while kraming.") - def changeConfig(self, newCf): + def changeConfig(self, newCf, acceptDeltaOverride=None): """ Apply a new cache‑type configuration using full Case‑3 (see KRAM specs), coverage‑aware semantics. This method enforces all KRAM invariants for safe dynamic @@ -1242,6 +1242,15 @@ def changeConfig(self, newCf): dictionary containing the new cache‑type configuration under: config["kram"]["caches"] + acceptDeltaOverride: + Optional runtime override injected at call time. + If absent, staging delays are computed automatically + (Case‑2 / Case‑3). If set to a non-negative integer + (milliseconds), that value is used as _pending[ctype]["delta"] + for every staged accept‑window update in this transition instead of + the computed delay. The admin/user is responsible for choosing + a safe value when overriding. + Behavior by case: • New cache‑type: @@ -1264,6 +1273,15 @@ def changeConfig(self, newCf): # Get the new config config = newCf.get() new = config.get("kram", {}).get("caches", {}) + if acceptDeltaOverride is not None: + try: + acceptDeltaOverride = int(acceptDeltaOverride) + if acceptDeltaOverride < 0: + raise ValueError + except (TypeError, ValueError) as e: + raise KramConfigurationError( + f"Invalid kram.acceptDeltaOverride: {acceptDeltaOverride!r}" + ) from e newRecords = self._validateCtypConfig(new) # Case 3 coverage aware logic @@ -1323,7 +1341,7 @@ def changeConfig(self, newCf): "ll_new": ll_new, "xl_new": xl_new, "start": start, - "delta": deltaCase3, + "delta": acceptDeltaOverride if acceptDeltaOverride is not None else deltaCase3, } # Populate the new Cache record, note that pruning values are immediately updated @@ -1391,7 +1409,7 @@ def changeConfig(self, newCf): "ll_new": ll_new, "xl_new": xl_new, "start": start, - "delta": delta, + "delta": acceptDeltaOverride if acceptDeltaOverride is not None else delta, } # Create cache record with the new values diff --git a/src/keri/kering.py b/src/keri/kering.py index a4f0dc539..c39223d2d 100644 --- a/src/keri/kering.py +++ b/src/keri/kering.py @@ -50,7 +50,7 @@ VEREX2 = ( b'(?P[A-Z]{4})' b'(?P[0-9A-Za-z_-])(?P[0-9A-Za-z_-]{2})' b'(?P[0-9A-Za-z_-])(?P[0-9A-Za-z_-]{2})' - b'(?P[A-Z]{4})(?P[0-9A-Za-z_-]{4})\.') + b'(?P[A-Z]{4})(?P[0-9A-Za-z_-]{4})\\.') VEREX = VEREX2 + b'|' + VEREX1 diff --git a/tests/core/test_kraming.py b/tests/core/test_kraming.py index ede96baee..742776651 100644 --- a/tests/core/test_kraming.py +++ b/tests/core/test_kraming.py @@ -2743,6 +2743,121 @@ def test_dynamic_cache_increase(fakeHelpingClock): assert "~" not in kramer._pending +def test_change_config_accept_delta_override_larger(fakeHelpingClock): + """Runtime acceptDeltaOverride longer than computed delta delays reconcileConfig.""" + clock = fakeHelpingClock + salt_receiver = Salter(raw=b'0123456789abcdeg').qb64 + + with openHby(name="receiver", base="test", salt=salt_receiver, temp=True) as receiverHby: + old_cfg = { + "kram": { + "enabled": True, + "caches": {"~": [1000, 1000, 1000, 1000, 1000, 1000, 1000]}, + } + } + with openCF(name="kram", base="test", temp=True) as cf: + cf.put(old_cfg) + kramer = Kramer(db=receiverHby.db, cf=cf) + + new_cfg = { + "kram": { + "enabled": True, + "caches": {"~": [1000, 5000, 5000, 5000, 5000, 5000, 5000]}, + } + } + cf.put(new_cfg) + kramer.changeConfig(cf, acceptDeltaOverride=10_000) + + pend = kramer._pending["~"] + assert pend["delta"] == 10_000 + + clock.advance(seconds=4) + kramer.reconcileConfig() + rec = receiverHby.db.kramCTYP.get("~") + assert rec.sl == 1000 + assert "~" in kramer._pending + + clock.advance(seconds=6) + kramer.reconcileConfig() + rec = receiverHby.db.kramCTYP.get("~") + assert rec.sl == 5000 + assert "~" not in kramer._pending + + +def test_change_config_accept_delta_override_smaller(fakeHelpingClock): + """Runtime acceptDeltaOverride shorter than computed delta allows earlier reconcileConfig.""" + clock = fakeHelpingClock + salt_receiver = Salter(raw=b'0123456789abcdeg').qb64 + + with openHby(name="receiver", base="test", salt=salt_receiver, temp=True) as receiverHby: + old_cfg = { + "kram": { + "enabled": True, + "caches": {"~": [1000, 1000, 1000, 1000, 1000, 1000, 1000]}, + } + } + with openCF(name="kram", base="test", temp=True) as cf: + cf.put(old_cfg) + kramer = Kramer(db=receiverHby.db, cf=cf) + + new_cfg = { + "kram": { + "enabled": True, + "caches": {"~": [1000, 5000, 5000, 5000, 5000, 5000, 5000]}, + } + } + cf.put(new_cfg) + kramer.changeConfig(cf, acceptDeltaOverride="2000") + + assert kramer._pending["~"]["delta"] == 2000 + + clock.advance(seconds=1) + kramer.reconcileConfig() + rec = receiverHby.db.kramCTYP.get("~") + assert rec.sl == 1000 + assert "~" in kramer._pending + + clock.advance(seconds=1) + kramer.reconcileConfig() + rec = receiverHby.db.kramCTYP.get("~") + assert rec.sl == 5000 + assert "~" not in kramer._pending + + +def test_change_config_accept_delta_invalid(): + """Non-integer or negative acceptDeltaOverride raises KramConfigurationError.""" + salt_receiver = Salter(raw=b'0123456789abcdeg').qb64 + + with openHby(name="receiver", base="test", salt=salt_receiver, temp=True) as receiverHby: + old_cfg = { + "kram": { + "enabled": True, + "caches": {"~": [1000, 1000, 1000, 1000, 1000, 1000, 1000]}, + } + } + with openCF(name="kram", base="test", temp=True) as cf: + cf.put(old_cfg) + kramer = Kramer(db=receiverHby.db, cf=cf) + + cf.put({ + "kram": { + "enabled": True, + "caches": {"~": [1000, 5000, 5000, 5000, 5000, 5000, 5000]}, + } + }) + with pytest.raises(KramConfigurationError): + kramer.changeConfig(cf, acceptDeltaOverride=-1) + + cf.put({ + "kram": { + "enabled": True, + "caches": {"~": [1000, 5000, 5000, 5000, 5000, 5000, 5000]}, + } + }) + with pytest.raises(KramConfigurationError): + kramer.changeConfig(cf, acceptDeltaOverride="notint") + + def test_dynamic_cache_decrease(fakeHelpingClock): """ Tests that Kramer.changeConfig() correctly applies: From 84cc19c59015161333ba4f04c41e4b229e457a97 Mon Sep 17 00:00:00 2001 From: ryan-hansen Date: Tue, 31 Mar 2026 17:20:05 -0600 Subject: [PATCH 2/2] Ensure override short-circuits delta calculation --- src/keri/core/kraming.py | 98 +++++++++++++++++--------------------- tests/core/test_kraming.py | 19 ++++++-- 2 files changed, 59 insertions(+), 58 deletions(-) diff --git a/src/keri/core/kraming.py b/src/keri/core/kraming.py index c47b49254..85a1de3ea 100644 --- a/src/keri/core/kraming.py +++ b/src/keri/core/kraming.py @@ -1205,7 +1205,7 @@ def kramit(self, msg, **kwa): else: raise KramError("Unexpected auth type while kraming.") - def changeConfig(self, newCf, acceptDeltaOverride=None): + def changeConfig(self, newCf, deltaOverride=None): """ Apply a new cache‑type configuration using full Case‑3 (see KRAM specs), coverage‑aware semantics. This method enforces all KRAM invariants for safe dynamic @@ -1242,14 +1242,15 @@ def changeConfig(self, newCf, acceptDeltaOverride=None): dictionary containing the new cache‑type configuration under: config["kram"]["caches"] - acceptDeltaOverride: + deltaOverride: Optional runtime override injected at call time. If absent, staging delays are computed automatically - (Case‑2 / Case‑3). If set to a non-negative integer - (milliseconds), that value is used as _pending[ctype]["delta"] - for every staged accept‑window update in this transition instead of - the computed delay. The admin/user is responsible for choosing - a safe value when overriding. + (Case‑2 / Case‑3), including Case‑3 coverage diff and worst‑case + delta. If set to a positive integer (milliseconds), that value + is used as ``_pending[ctype]["delta"]`` for every staged update + and no Case‑3 delta computation is performed. Newly introduced + cache‑types are staged with this delay. The admin/user is + responsible for choosing a safe value. Behavior by case: @@ -1273,14 +1274,14 @@ def changeConfig(self, newCf, acceptDeltaOverride=None): # Get the new config config = newCf.get() new = config.get("kram", {}).get("caches", {}) - if acceptDeltaOverride is not None: + if deltaOverride is not None: try: - acceptDeltaOverride = int(acceptDeltaOverride) - if acceptDeltaOverride < 0: + deltaOverride = int(deltaOverride) + if deltaOverride <= 0: raise ValueError except (TypeError, ValueError) as e: raise KramConfigurationError( - f"Invalid kram.acceptDeltaOverride: {acceptDeltaOverride!r}" + f"Invalid kram.acceptDeltaOverride: {deltaOverride!r}" ) from e newRecords = self._validateCtypConfig(new) @@ -1292,11 +1293,11 @@ def changeConfig(self, newCf, acceptDeltaOverride=None): # Validate coverage (no coverage holes) self._validateCoverage(oldGraph, newGraph, new) - # Compute coverage diff - coverageDiff = self._computeCoverageDiff(oldGraph, newGraph) - - # Compute worst-case delta across coverage - deltaCase3 = self._computeWorstCaseDelta(coverageDiff, old, new) + if deltaOverride is None: + coverageDiff = self._computeCoverageDiff(oldGraph, newGraph) + deltaCase3 = self._computeWorstCaseDelta(coverageDiff, old, new) + else: + deltaCase3 = None # Get the smallest old accept windows so that it cannot accept # messages earlier than any existing cache‑type @@ -1322,40 +1323,32 @@ def changeConfig(self, newCf, acceptDeltaOverride=None): # Newly introduced cache if ctype not in old: - # No expansion detected in the coverage graph - if deltaCase3 == 0: - # Safe to apply immediately + if deltaOverride is not None: + delta = deltaOverride + elif deltaCase3 == 0: rec = newrec self.db.kramCTYP.pin(ctype, rec) - - # Pattern in the coverage graph expanded, accept-window increases must be staged + continue else: - # Stage accept windows using Case 3 delta - # Get staging start time - start = helping.fromIso8601(helping.nowIso8601()).timestamp() * 1000 - - # Populate pending with the new values - self._pending[ctype] = { - "d_new": d_new, - "sl_new": sl_new, - "ll_new": ll_new, - "xl_new": xl_new, - "start": start, - "delta": acceptDeltaOverride if acceptDeltaOverride is not None else deltaCase3, - } - - # Populate the new Cache record, note that pruning values are immediately updated - # while we use the smallest accept-window values determined earlier - rec = CacheTypeRecord( - d=d_new, - sl=min_sl, ll=min_ll, xl=min_xl, - psl=max(psl_new, sl_new), - pll=max(pll_new, ll_new), - pxl=max(pxl_new, xl_new), - ) - - # Update the cache record inside db - self.db.kramCTYP.pin(ctype, rec) + delta = deltaCase3 + + start = helping.fromIso8601(helping.nowIso8601()).timestamp() * 1000 + self._pending[ctype] = { + "d_new": d_new, + "sl_new": sl_new, + "ll_new": ll_new, + "xl_new": xl_new, + "start": start, + "delta": delta, + } + rec = CacheTypeRecord( + d=d_new, + sl=min_sl, ll=min_ll, xl=min_xl, + psl=max(psl_new, sl_new), + pll=max(pll_new, ll_new), + pxl=max(pxl_new, xl_new), + ) + self.db.kramCTYP.pin(ctype, rec) continue # Cache is already in old config, determine if case 1 or case 2 @@ -1396,20 +1389,19 @@ def changeConfig(self, newCf, acceptDeltaOverride=None): d_xl = max(0, xl_new - xl_old) deltaCase2 = max(d_sl, d_ll, d_xl) - # Unified delta ensures safety across Case 2 and Case 3 - delta = max(deltaCase2, deltaCase3) + if deltaOverride is None: + delta = max(deltaCase2, deltaCase3) + else: + delta = deltaOverride - # Get the start time of the change start = helping.fromIso8601(helping.nowIso8601()).timestamp() * 1000 - - # Populate pending with the new values self._pending[ctype] = { "d_new": d_new, "sl_new": sl_new, "ll_new": ll_new, "xl_new": xl_new, "start": start, - "delta": acceptDeltaOverride if acceptDeltaOverride is not None else delta, + "delta": delta, } # Create cache record with the new values diff --git a/tests/core/test_kraming.py b/tests/core/test_kraming.py index 742776651..025ca3d5e 100644 --- a/tests/core/test_kraming.py +++ b/tests/core/test_kraming.py @@ -2766,7 +2766,7 @@ def test_change_config_accept_delta_override_larger(fakeHelpingClock): } } cf.put(new_cfg) - kramer.changeConfig(cf, acceptDeltaOverride=10_000) + kramer.changeConfig(cf, deltaOverride=10_000) pend = kramer._pending["~"] assert pend["delta"] == 10_000 @@ -2807,7 +2807,7 @@ def test_change_config_accept_delta_override_smaller(fakeHelpingClock): } } cf.put(new_cfg) - kramer.changeConfig(cf, acceptDeltaOverride="2000") + kramer.changeConfig(cf, deltaOverride="2000") assert kramer._pending["~"]["delta"] == 2000 @@ -2825,7 +2825,7 @@ def test_change_config_accept_delta_override_smaller(fakeHelpingClock): def test_change_config_accept_delta_invalid(): - """Non-integer or negative acceptDeltaOverride raises KramConfigurationError.""" + """Non-integer or non-positive acceptDeltaOverride raises KramConfigurationError.""" salt_receiver = Salter(raw=b'0123456789abcdeg').qb64 with openHby(name="receiver", base="test", salt=salt_receiver, temp=True) as receiverHby: @@ -2846,7 +2846,7 @@ def test_change_config_accept_delta_invalid(): } }) with pytest.raises(KramConfigurationError): - kramer.changeConfig(cf, acceptDeltaOverride=-1) + kramer.changeConfig(cf, deltaOverride=-1) cf.put({ "kram": { @@ -2855,7 +2855,16 @@ def test_change_config_accept_delta_invalid(): } }) with pytest.raises(KramConfigurationError): - kramer.changeConfig(cf, acceptDeltaOverride="notint") + kramer.changeConfig(cf, deltaOverride=0) + + cf.put({ + "kram": { + "enabled": True, + "caches": {"~": [1000, 5000, 5000, 5000, 5000, 5000, 5000]}, + } + }) + with pytest.raises(KramConfigurationError): + kramer.changeConfig(cf, deltaOverride="notint") def test_dynamic_cache_decrease(fakeHelpingClock):