superlocalmemory 3.8.3 → 3.8.6

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.
Files changed (125) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +9 -4
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +158 -404
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/component_registry.py +4 -2
  40. package/src/superlocalmemory/core/config.py +78 -0
  41. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  42. package/src/superlocalmemory/core/embeddings.py +33 -6
  43. package/src/superlocalmemory/core/engine.py +186 -60
  44. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  45. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  46. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  47. package/src/superlocalmemory/core/ingestion_command.py +273 -32
  48. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  49. package/src/superlocalmemory/core/mutations.py +32 -10
  50. package/src/superlocalmemory/core/recall_pipeline.py +111 -74
  51. package/src/superlocalmemory/core/registry.py +5 -1
  52. package/src/superlocalmemory/core/remember_admission.py +152 -0
  53. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  54. package/src/superlocalmemory/core/remote_mode.py +3 -1
  55. package/src/superlocalmemory/core/scale_engine.py +41 -18
  56. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  57. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  58. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  59. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  60. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  61. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  62. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  63. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  64. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  65. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  66. package/src/superlocalmemory/infra/event_bus.py +250 -88
  67. package/src/superlocalmemory/learning/bandit.py +50 -1
  68. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  69. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  70. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  71. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  72. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  73. package/src/superlocalmemory/learning/source_quality.py +38 -35
  74. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  75. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  76. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  77. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  78. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  79. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  80. package/src/superlocalmemory/retrieval/engine.py +15 -4
  81. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  82. package/src/superlocalmemory/retrieval/reranker.py +130 -22
  83. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  84. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  85. package/src/superlocalmemory/server/loopback.py +85 -0
  86. package/src/superlocalmemory/server/origin.py +9 -4
  87. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  88. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  89. package/src/superlocalmemory/server/routes/agents.py +3 -5
  90. package/src/superlocalmemory/server/routes/backup.py +6 -2
  91. package/src/superlocalmemory/server/routes/behavioral.py +11 -25
  92. package/src/superlocalmemory/server/routes/brain.py +6 -9
  93. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  94. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  95. package/src/superlocalmemory/server/routes/entity.py +3 -7
  96. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  97. package/src/superlocalmemory/server/routes/helpers.py +57 -25
  98. package/src/superlocalmemory/server/routes/insights.py +2 -4
  99. package/src/superlocalmemory/server/routes/learning.py +2 -5
  100. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  101. package/src/superlocalmemory/server/routes/memories.py +119 -98
  102. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  103. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  104. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  105. package/src/superlocalmemory/server/routes/tiers.py +28 -35
  106. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  107. package/src/superlocalmemory/server/routes/v3_api.py +85 -93
  108. package/src/superlocalmemory/server/unified_daemon.py +400 -140
  109. package/src/superlocalmemory/server/write_identity.py +22 -4
  110. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  111. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  112. package/src/superlocalmemory/storage/database.py +168 -19
  113. package/src/superlocalmemory/storage/deferred_writes.py +209 -0
  114. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  115. package/src/superlocalmemory/storage/memory_write.py +115 -0
  116. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  117. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  118. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  119. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  120. package/src/superlocalmemory/storage/read_connection.py +115 -0
  121. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  122. package/src/superlocalmemory/storage/write_lock.py +88 -0
  123. package/src/superlocalmemory/ui/index.html +1 -1
  124. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  125. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -14,6 +14,8 @@ from __future__ import annotations
14
14
  import hashlib
15
15
  import hmac
16
16
  import logging
17
+ import sqlite3
18
+ from pathlib import Path
17
19
  from typing import TYPE_CHECKING, Any
18
20
 
19
21
  if TYPE_CHECKING:
@@ -212,6 +214,70 @@ def _behavioral_entities(results: list[Any], limit: int = 20) -> list[str]:
212
214
  _RANKING_MODES: frozenset[str] = frozenset({"off", "v1", "v2", "v2-ensemble"})
213
215
 
214
216
 
217
+ class _ReadOnlyLearningView:
218
+ """Minimal learning-model reader that cannot initialise or mutate a DB."""
219
+
220
+ def __init__(self, db_path: Path) -> None:
221
+ self._db_path = db_path.resolve()
222
+
223
+ def _connection(self) -> sqlite3.Connection:
224
+ connection = sqlite3.connect(
225
+ f"{self._db_path.as_uri()}?mode=ro",
226
+ uri=True,
227
+ timeout=0.25,
228
+ )
229
+ connection.row_factory = sqlite3.Row
230
+ connection.execute("PRAGMA query_only=ON")
231
+ connection.execute("PRAGMA busy_timeout=250")
232
+ return connection
233
+
234
+ def count_signals(self, profile_id: str) -> int:
235
+ connection = self._connection()
236
+ try:
237
+ row = connection.execute(
238
+ "SELECT COUNT(*) AS count FROM learning_signals "
239
+ "WHERE profile_id = ?",
240
+ (profile_id,),
241
+ ).fetchone()
242
+ return int(row["count"]) if row else 0
243
+ finally:
244
+ connection.close()
245
+
246
+ def count_feedback(self, profile_id: str) -> int:
247
+ """Count legacy feedback without running schema initialization."""
248
+ connection = self._connection()
249
+ try:
250
+ row = connection.execute(
251
+ "SELECT COUNT(*) AS count FROM learning_feedback "
252
+ "WHERE profile_id = ?",
253
+ (profile_id,),
254
+ ).fetchone()
255
+ return int(row["count"]) if row else 0
256
+ finally:
257
+ connection.close()
258
+
259
+ def load_active_model(self, profile_id: str) -> dict[str, Any] | None:
260
+ connection = self._connection()
261
+ try:
262
+ row = connection.execute(
263
+ "SELECT state_bytes, bytes_sha256, feature_names, trained_at, "
264
+ "model_version FROM learning_model_state "
265
+ "WHERE profile_id = ? AND is_active = 1 LIMIT 1",
266
+ (profile_id,),
267
+ ).fetchone()
268
+ if row is None:
269
+ return None
270
+ return {
271
+ "state_bytes": bytes(row["state_bytes"]),
272
+ "bytes_sha256": row["bytes_sha256"],
273
+ "feature_names": row["feature_names"],
274
+ "trained_at": row["trained_at"],
275
+ "model_version": row["model_version"],
276
+ }
277
+ finally:
278
+ connection.close()
279
+
280
+
215
281
  def _resolve_ranking_mode(env: "dict[str, str] | os._Environ[str]") -> str:
216
282
  """Map the ``SLM_RANKING`` env var to a canonical mode.
217
283
 
@@ -239,6 +305,7 @@ def apply_ranking(
239
305
  *,
240
306
  config: Any = None,
241
307
  pipeline_version: str = "v2-ensemble",
308
+ record_signals: bool = False,
242
309
  ) -> "RecallResponse":
243
310
  """Run the ranking pipeline at the requested version.
244
311
 
@@ -273,6 +340,7 @@ def apply_ranking(
273
340
  try:
274
341
  response = apply_v2_bandit_ensemble(
275
342
  response, query, profile_id, query_id,
343
+ record_signals=record_signals,
276
344
  )
277
345
  except Exception as exc: # pragma: no cover — defensive
278
346
  logger.debug("apply_ranking ensemble step skipped: %s", exc)
@@ -297,14 +365,16 @@ def apply_adaptive_ranking(
297
365
  Phase 3 (200+): LightGBM ML-based reranking.
298
366
  """
299
367
  from superlocalmemory.infra.data_root import state_path
300
- from superlocalmemory.learning.feedback import FeedbackCollector
301
-
302
368
  learning_db = state_path("learning.db")
303
369
  if not learning_db.exists():
304
370
  return response
305
371
 
306
- collector = FeedbackCollector(learning_db)
307
- signal_count = collector.get_feedback_count(pid)
372
+ try:
373
+ signal_count = _ReadOnlyLearningView(learning_db).count_feedback(pid)
374
+ except sqlite3.Error:
375
+ # A pre-learning database may not have this optional table yet.
376
+ # Recall remains a query and cannot create it on demand.
377
+ return response
308
378
 
309
379
  if signal_count < 50:
310
380
  return response # Phase 1: no change
@@ -388,7 +458,6 @@ def apply_v2_adaptive_ranking(
388
458
  from pathlib import Path as _P
389
459
 
390
460
  from superlocalmemory.infra.data_root import state_path
391
- from superlocalmemory.learning.database import LearningDatabase
392
461
  from superlocalmemory.learning.model_cache import load_active
393
462
  from superlocalmemory.learning.ranker import AdaptiveRanker
394
463
 
@@ -397,7 +466,7 @@ def apply_v2_adaptive_ranking(
397
466
  if not db_path.exists():
398
467
  return response
399
468
 
400
- db = LearningDatabase(db_path)
469
+ db = _ReadOnlyLearningView(db_path)
401
470
  signal_count = db.count_signals(profile_id)
402
471
  active = load_active(db, profile_id)
403
472
 
@@ -487,6 +556,7 @@ def apply_v2_bandit_ensemble(
487
556
  query_id: str,
488
557
  *,
489
558
  learning_db_path: Any = None,
559
+ record_signals: bool = False,
490
560
  ) -> RecallResponse:
491
561
  """Apply contextual bandit + optional LGBM ensemble rerank. Safe on error."""
492
562
  import os as _os
@@ -521,12 +591,14 @@ def apply_v2_bandit_ensemble(
521
591
  entity_count = 0
522
592
  # Use query_context hints if available on the engine — cheap fallback.
523
593
  bandit = ContextualBandit(db_path, profile_id)
524
- choice = bandit.choose(
525
- {
526
- "query_type": response.query_type,
527
- "entity_count": entity_count,
528
- },
529
- query_id,
594
+ context = {
595
+ "query_type": response.query_type,
596
+ "entity_count": entity_count,
597
+ }
598
+ choice = (
599
+ bandit.choose(context, query_id)
600
+ if record_signals
601
+ else bandit.choose_readonly(context)
530
602
  )
531
603
 
532
604
  # --- 2. apply channel weights -------------------------------------
@@ -536,9 +608,8 @@ def apply_v2_bandit_ensemble(
536
608
  active_model = None
537
609
  signal_count = 0
538
610
  try:
539
- from superlocalmemory.learning.database import LearningDatabase
540
611
  from superlocalmemory.learning.model_cache import load_active
541
- db = LearningDatabase(db_path)
612
+ db = _ReadOnlyLearningView(db_path)
542
613
  signal_count = db.count_signals(profile_id)
543
614
  active_model = load_active(db, profile_id)
544
615
  except Exception as exc:
@@ -561,28 +632,32 @@ def apply_v2_bandit_ensemble(
561
632
  logger.debug("v2 bandit ensemble_rerank skipped: %s", exc)
562
633
  final_results = weighted
563
634
 
564
- # --- 5. enqueue signals (non-blocking) ----------------------------
565
- try:
566
- top20 = final_results[:20]
567
- candidates = tuple(
568
- SignalCandidate(
569
- fact_id=r.fact.fact_id,
570
- channel_scores=dict(r.channel_scores or {}),
571
- cross_encoder_score=None,
572
- result_dict={"fact_id": r.fact.fact_id,
573
- "score": r.score},
635
+ # Recall is a query. Implicit learning signals are deliberately
636
+ # disabled on this path: even a non-blocking enqueue eventually writes
637
+ # canonical/learning state and turns dashboard polling into contention.
638
+ # An explicit feedback command owns durable learning signals instead.
639
+ if record_signals:
640
+ try:
641
+ top20 = final_results[:20]
642
+ candidates = tuple(
643
+ SignalCandidate(
644
+ fact_id=r.fact.fact_id,
645
+ channel_scores=dict(r.channel_scores or {}),
646
+ cross_encoder_score=None,
647
+ result_dict={"fact_id": r.fact.fact_id,
648
+ "score": r.score},
649
+ )
650
+ for r in top20
574
651
  )
575
- for r in top20
576
- )
577
- enqueue(SignalBatch(
578
- profile_id=profile_id,
579
- query_id=query_id,
580
- query_text=query,
581
- candidates=candidates,
582
- query_context=query_context,
583
- ))
584
- except Exception as exc:
585
- logger.debug("v2 bandit signal enqueue skipped: %s", exc)
652
+ enqueue(SignalBatch(
653
+ profile_id=profile_id,
654
+ query_id=query_id,
655
+ query_text=query,
656
+ candidates=candidates,
657
+ query_context=query_context,
658
+ ))
659
+ except Exception as exc:
660
+ logger.debug("v2 bandit signal enqueue skipped: %s", exc)
586
661
 
587
662
  return RecallResponse(
588
663
  query=response.query,
@@ -665,15 +740,6 @@ def run_recall(
665
740
  agent hot path skips the internal round and delegates refinement to the
666
741
  calling LLM. ``fast=False`` forces the internal agentic round.
667
742
  """
668
- # Pre-operation hooks
669
- hook_ctx = {
670
- "operation": "recall",
671
- "agent_id": agent_id,
672
- "profile_id": profile_id,
673
- "query_preview": query[:100],
674
- }
675
- hooks.run_pre("recall", hook_ctx)
676
-
677
743
  m = mode or config.mode
678
744
 
679
745
  # v3.8.2: resolve the client-driven-agentic default when a caller left
@@ -772,30 +838,6 @@ def run_recall(
772
838
  logger.debug("Agentic sufficiency skipped: %s", exc)
773
839
 
774
840
  _mark("agentic")
775
- # V3.2: Log access for recalled facts (Phase 1)
776
- if access_log and response.results:
777
- try:
778
- fact_ids = [r.fact.fact_id for r in response.results]
779
- access_log.store_access_batch(
780
- fact_ids=fact_ids,
781
- profile_id=profile_id,
782
- access_type="recall",
783
- )
784
- except Exception as exc:
785
- logger.debug("Access log batch store failed: %s", exc)
786
-
787
- # Query telemetry is permitted, but result-derived entities are not fed
788
- # back as preferences. Retrieval exposure is not positive feedback.
789
- try:
790
- _get_behavioral_tracker(db).record_query(
791
- query=query,
792
- query_type=response.query_type,
793
- entities=[],
794
- profile_id=profile_id,
795
- )
796
- except Exception as exc:
797
- logger.debug("Behavioral tracking: %s", exc)
798
-
799
841
  # S8-ARC-04 (v3.4.22): unified ranking entry point. Single env-var
800
842
  # (SLM_RANKING=off|v1|v2|v2-ensemble) controls the pipeline. Legacy
801
843
  # SLM_V2_PIPELINE_DISABLED + SLM_BANDIT_DISABLED still honoured for
@@ -807,7 +849,7 @@ def run_recall(
807
849
  mode = _resolve_ranking_mode(_os.environ)
808
850
  response = apply_ranking(
809
851
  response, query, profile_id, query_id,
810
- config=config, pipeline_version=mode,
852
+ config=config, pipeline_version=mode, record_signals=False,
811
853
  )
812
854
  except Exception as exc:
813
855
  logger.debug("Ranking pipeline skipped: %s", exc)
@@ -817,11 +859,6 @@ def run_recall(
817
859
  # mutation here. Those state transitions require a separately authenticated
818
860
  # positive/negative outcome; merely returning a result is an exposure.
819
861
 
820
- # Post-operation hooks (audit, trust signal, learning)
821
- hook_ctx["result_count"] = len(response.results)
822
- hook_ctx["query_type"] = response.query_type
823
- hooks.run_post("recall", hook_ctx)
824
-
825
862
  from superlocalmemory.core.score_contract import finalize_score_contract
826
863
  finalize_score_contract(response)
827
864
 
@@ -110,7 +110,11 @@ class AgentRegistry:
110
110
  try:
111
111
  data = json.loads(self._path.read_text(encoding="utf-8"))
112
112
  self._agents = data.get("agents", {})
113
- self._write_locks = data.get("write_locks", {})
113
+ # D-01: write_locks from a prior process are meaningless after a
114
+ # process boundary — the agents that held them are dead. Starting
115
+ # with no inherited locks prevents mcp_client (and any other agent)
116
+ # from being permanently blocked on every daemon restart.
117
+ self._write_locks = {}
114
118
  except (json.JSONDecodeError, KeyError):
115
119
  logger.warning("Corrupt registry file at %s — starting fresh.", self._path)
116
120
  self._agents = {}
@@ -0,0 +1,152 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Journal-first service boundary for a durable, immediately-queryable remember."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import time
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from typing import Any, Protocol
13
+
14
+ from superlocalmemory.storage.admission_journal import (
15
+ Actor,
16
+ AdmissionJournal,
17
+ AdmissionJournalUnavailable,
18
+ PreparedAdmission,
19
+ RememberRequest,
20
+ TerminalAdmissionError,
21
+ )
22
+
23
+
24
+ class AdmissionRejected(RuntimeError):
25
+ """Canonical admission was not committed; the journal records its outcome."""
26
+
27
+ def __init__(self, error_code: str, *, retryable: bool = False) -> None:
28
+ super().__init__(error_code)
29
+ self.error_code = error_code
30
+ self.retryable = retryable
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class RememberReceipt:
35
+ """Canonical receipt returned only after admission is queryable."""
36
+
37
+ payload: dict[str, Any]
38
+
39
+ @classmethod
40
+ def from_mapping(cls, receipt: Mapping[str, Any]) -> RememberReceipt:
41
+ return cls(payload=dict(receipt))
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class RememberAdmissionCommand:
46
+ """Typed foreground input for a coordinator's bounded canonical transaction."""
47
+
48
+ journal_id: str
49
+ request_hash: str
50
+ request: RememberRequest
51
+ profile_id: str
52
+ idempotency_key: str
53
+
54
+ @classmethod
55
+ def from_prepared(
56
+ cls, prepared: PreparedAdmission, request: RememberRequest
57
+ ) -> RememberAdmissionCommand:
58
+ return cls(
59
+ journal_id=prepared.journal_id,
60
+ request_hash=prepared.request_hash,
61
+ request=request,
62
+ profile_id=prepared.profile_id,
63
+ idempotency_key=prepared.idempotency_key,
64
+ )
65
+
66
+
67
+ class RememberCoordinator(Protocol):
68
+ def submit(self, command: RememberAdmissionCommand, *, wait_ms: int) -> Any: ...
69
+
70
+
71
+ class RememberService:
72
+ """Apply the journaling pattern without doing enrichment inside admission."""
73
+
74
+ def __init__(self, journal: AdmissionJournal, coordinator: RememberCoordinator) -> None:
75
+ self._journal = journal
76
+ self._coordinator = coordinator
77
+
78
+ def remember(
79
+ self, request: RememberRequest, actor: Actor, *, deadline_ms: int
80
+ ) -> RememberReceipt:
81
+ if deadline_ms <= 0:
82
+ raise ValueError("deadline_ms must be greater than zero")
83
+ deadline = time.monotonic() + deadline_ms / 1_000
84
+ prepared = self._journal.prepare(
85
+ request,
86
+ actor,
87
+ deadline=deadline,
88
+ )
89
+ if prepared.original_receipt is not None:
90
+ return RememberReceipt.from_mapping(prepared.original_receipt)
91
+ if prepared.state == "rejected":
92
+ raise AdmissionRejected(prepared.error_code or "COMMAND_REJECTED")
93
+
94
+ command_request = self._journal.request_for(
95
+ prepared,
96
+ deadline=deadline,
97
+ )
98
+ dispatched = self._journal.mark_dispatched(
99
+ prepared.journal_id,
100
+ deadline=deadline,
101
+ )
102
+ if dispatched.original_receipt is not None:
103
+ return RememberReceipt.from_mapping(dispatched.original_receipt)
104
+ try:
105
+ result = self._coordinator.submit(
106
+ RememberAdmissionCommand.from_prepared(prepared, command_request),
107
+ wait_ms=_remaining_milliseconds(deadline),
108
+ )
109
+ except TerminalAdmissionError as exc:
110
+ self._journal.mark_rejected(
111
+ prepared.journal_id,
112
+ exc.error_code,
113
+ deadline=deadline,
114
+ )
115
+ raise AdmissionRejected(exc.error_code) from exc
116
+ state = _result_value(result, "state")
117
+ receipt = _result_value(result, "receipt") or {}
118
+ if state in {"committed", "duplicate"}:
119
+ if not isinstance(receipt, Mapping):
120
+ raise AdmissionRejected("COMMAND_REJECTED: canonical result had no receipt")
121
+ committed = self._journal.mark_committed(
122
+ prepared.journal_id,
123
+ receipt,
124
+ deadline=deadline,
125
+ )
126
+ return RememberReceipt.from_mapping(committed.original_receipt or receipt)
127
+
128
+ error_code = str(_result_value(result, "error_code") or "COMMAND_REJECTED")
129
+ if state == "rejected":
130
+ self._journal.mark_rejected(
131
+ prepared.journal_id,
132
+ error_code,
133
+ deadline=deadline,
134
+ )
135
+ raise AdmissionRejected(error_code, retryable=state != "rejected")
136
+
137
+
138
+ def _result_value(result: Any, key: str) -> Any:
139
+ if isinstance(result, Mapping):
140
+ return result.get(key)
141
+ return getattr(result, key, None)
142
+
143
+
144
+ def _remaining_seconds(deadline: float) -> float:
145
+ remaining = deadline - time.monotonic()
146
+ if remaining <= 0:
147
+ raise AdmissionJournalUnavailable("remember admission deadline expired")
148
+ return remaining
149
+
150
+
151
+ def _remaining_milliseconds(deadline: float) -> int:
152
+ return max(1, math.ceil(_remaining_seconds(deadline) * 1_000))