Add durable search event indexing contract
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
# Search event indexing contract
|
||||||
|
|
||||||
|
Core defines, but does not implement, the optional Search indexing boundary.
|
||||||
|
Feature modules register `SearchSourceProvider` implementations for bounded
|
||||||
|
backfills and live authorization checks. A provider may additionally implement
|
||||||
|
`SearchEventSourceProvider` to translate a committed `PlatformEvent` into one
|
||||||
|
or more authoritative `SearchIndexChange` values.
|
||||||
|
|
||||||
|
When the Search index-writer capability is active, the platform event worker
|
||||||
|
uses the durable consumer identity `search.indexing.v1`. It accepts only public
|
||||||
|
and internal events, passes the outbox delivery key to each event-capable
|
||||||
|
source, and then advances a bounded batch of queued index changes in the same
|
||||||
|
worker transaction. Stable change IDs make delivery replay idempotent.
|
||||||
|
|
||||||
|
The boundary has three non-negotiable rules:
|
||||||
|
|
||||||
|
- a source may emit changes only for its registered module, provider, resource
|
||||||
|
type, and event tenant;
|
||||||
|
- Search validates every upsert document before queueing it and rejects secret
|
||||||
|
metadata keys;
|
||||||
|
- an index ACL is only a candidate filter. Resources marked for authorization
|
||||||
|
recheck are returned only after the owning source explicitly allows the
|
||||||
|
current principal at query time.
|
||||||
|
|
||||||
|
Search and its worker remain optional. Core-only startup and feature-module
|
||||||
|
operation do not require the Search package.
|
||||||
@@ -75,6 +75,10 @@ from govoplan_core.core.runtime_coordination import (
|
|||||||
runtime_identity,
|
runtime_identity,
|
||||||
stop_runtime_node,
|
stop_runtime_node,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
CAPABILITY_SEARCH_INDEX_WRITER,
|
||||||
|
SearchIndexCoordinator,
|
||||||
|
)
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
from govoplan_core.db.session import configure_database, get_database
|
from govoplan_core.db.session import configure_database, get_database
|
||||||
from govoplan_core.server.registry import (
|
from govoplan_core.server.registry import (
|
||||||
@@ -528,6 +532,20 @@ def _platform_event_outbox(
|
|||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _search_index_coordinator(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> SearchIndexCoordinator | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_SEARCH_INDEX_WRITER):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(
|
||||||
|
CAPABILITY_SEARCH_INDEX_WRITER
|
||||||
|
)
|
||||||
|
if not isinstance(capability, SearchIndexCoordinator):
|
||||||
|
raise RuntimeError("Search index coordinator capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
def _idm_assignment_lifecycle(
|
def _idm_assignment_lifecycle(
|
||||||
registry: PlatformRegistry | None = None,
|
registry: PlatformRegistry | None = None,
|
||||||
) -> IdmAssignmentLifecycle | None:
|
) -> IdmAssignmentLifecycle | None:
|
||||||
@@ -884,6 +902,7 @@ def dispatch_platform_events(self, limit: int = 100):
|
|||||||
}
|
}
|
||||||
dataflow_dispatcher = _dataflow_trigger_dispatcher(registry)
|
dataflow_dispatcher = _dataflow_trigger_dispatcher(registry)
|
||||||
workflow_dispatcher = _workflow_trigger_dispatcher(registry)
|
workflow_dispatcher = _workflow_trigger_dispatcher(registry)
|
||||||
|
search_coordinator = _search_index_coordinator(registry)
|
||||||
consumers: list[DurableEventConsumer] = []
|
consumers: list[DurableEventConsumer] = []
|
||||||
if dataflow_dispatcher is not None:
|
if dataflow_dispatcher is not None:
|
||||||
|
|
||||||
@@ -923,6 +942,26 @@ def dispatch_platform_events(self, limit: int = 100):
|
|||||||
handler=deliver_to_workflow,
|
handler=deliver_to_workflow,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if search_coordinator is not None:
|
||||||
|
|
||||||
|
def deliver_to_search(
|
||||||
|
event: PlatformEvent,
|
||||||
|
delivery_key: str,
|
||||||
|
) -> None:
|
||||||
|
search_coordinator.ingest_event(
|
||||||
|
session,
|
||||||
|
event=event,
|
||||||
|
delivery_key=delivery_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
consumers.append(
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="search.indexing.v1",
|
||||||
|
event_types=frozenset({"*"}),
|
||||||
|
classifications=frozenset({"public", "internal"}),
|
||||||
|
handler=deliver_to_search,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
result = dict(
|
result = dict(
|
||||||
outbox.dispatch_pending(
|
outbox.dispatch_pending(
|
||||||
@@ -932,6 +971,13 @@ def dispatch_platform_events(self, limit: int = 100):
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if search_coordinator is not None:
|
||||||
|
result["search_changes"] = dict(
|
||||||
|
search_coordinator.process_changes(
|
||||||
|
session,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal, Protocol, runtime_checkable
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.events import PlatformEvent
|
||||||
from govoplan_core.core.external_references import ExternalObjectReference
|
from govoplan_core.core.external_references import ExternalObjectReference
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
|
||||||
@@ -379,6 +380,43 @@ class SearchSourceProvider(Protocol):
|
|||||||
"""Return an explicit decision for every requested reference key."""
|
"""Return an explicit decision for every requested reference key."""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SearchEventSourceProvider(Protocol):
|
||||||
|
"""Optional source extension for committed, idempotent index deltas."""
|
||||||
|
|
||||||
|
def index_changes_for_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
event: PlatformEvent,
|
||||||
|
delivery_key: str,
|
||||||
|
) -> Sequence[SearchIndexChange]:
|
||||||
|
"""Translate one committed event into authoritative index changes."""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SearchIndexCoordinator(Protocol):
|
||||||
|
"""Worker-facing orchestration surface exposed by the Search module."""
|
||||||
|
|
||||||
|
def ingest_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
event: PlatformEvent,
|
||||||
|
delivery_key: str,
|
||||||
|
) -> Mapping[str, int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def process_changes(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
) -> Mapping[str, int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
|
SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
|
||||||
SearchSourceProviderFactory = Callable[
|
SearchSourceProviderFactory = Callable[
|
||||||
[ModuleContext],
|
[ModuleContext],
|
||||||
@@ -455,8 +493,10 @@ __all__ = [
|
|||||||
"SearchBackfillRequest",
|
"SearchBackfillRequest",
|
||||||
"SearchContextKind",
|
"SearchContextKind",
|
||||||
"SearchDocument",
|
"SearchDocument",
|
||||||
|
"SearchEventSourceProvider",
|
||||||
"SearchIndexChange",
|
"SearchIndexChange",
|
||||||
"SearchIndexChangeKind",
|
"SearchIndexChangeKind",
|
||||||
|
"SearchIndexCoordinator",
|
||||||
"SearchIndexWriter",
|
"SearchIndexWriter",
|
||||||
"SearchProvider",
|
"SearchProvider",
|
||||||
"SearchProviderFactory",
|
"SearchProviderFactory",
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
"postbox",
|
"postbox",
|
||||||
"approvals",
|
"approvals",
|
||||||
"reporting",
|
"reporting",
|
||||||
|
"search",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(manifests["dashboard"].dependencies, ())
|
self.assertEqual(manifests["dashboard"].dependencies, ())
|
||||||
@@ -3517,7 +3518,7 @@ finally:
|
|||||||
"version_max_exclusive": "0.2.0",
|
"version_max_exclusive": "0.2.0",
|
||||||
}, modules["files"]["requires_interfaces"])
|
}, modules["files"]["requires_interfaces"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["campaigns", "encryption"],
|
["campaigns", "encryption", "search"],
|
||||||
modules["files"]["optional_dependencies"],
|
modules["files"]["optional_dependencies"],
|
||||||
)
|
)
|
||||||
self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"])
|
self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"])
|
||||||
@@ -3581,6 +3582,7 @@ finally:
|
|||||||
"postbox",
|
"postbox",
|
||||||
"approvals",
|
"approvals",
|
||||||
"reporting",
|
"reporting",
|
||||||
|
"search",
|
||||||
],
|
],
|
||||||
modules["campaigns"]["optional_dependencies"],
|
modules["campaigns"]["optional_dependencies"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
|||||||
"govoplan_core.celery_app._workflow_trigger_dispatcher",
|
"govoplan_core.celery_app._workflow_trigger_dispatcher",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._search_index_coordinator",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
patch(
|
patch(
|
||||||
"govoplan_core.db.session.get_database",
|
"govoplan_core.db.session.get_database",
|
||||||
return_value=database,
|
return_value=database,
|
||||||
@@ -77,6 +81,70 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
|||||||
session.commit.assert_called_once_with()
|
session.commit.assert_called_once_with()
|
||||||
self.assertEqual(1, result["delivered"])
|
self.assertEqual(1, result["delivered"])
|
||||||
|
|
||||||
|
def test_dispatch_uses_a_durable_search_consumer_and_processes_changes(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
database = MagicMock()
|
||||||
|
database.SessionLocal.return_value.__enter__.return_value = session
|
||||||
|
outbox = MagicMock()
|
||||||
|
outbox.dispatch_pending.return_value = {
|
||||||
|
"selected": 1,
|
||||||
|
"delivered": 1,
|
||||||
|
"retrying": 0,
|
||||||
|
"quarantined": 0,
|
||||||
|
"dispatched": 1,
|
||||||
|
"observer_failed": 0,
|
||||||
|
}
|
||||||
|
search = MagicMock()
|
||||||
|
search.process_changes.return_value = {
|
||||||
|
"selected": 1,
|
||||||
|
"applied": 1,
|
||||||
|
"retrying": 0,
|
||||||
|
"quarantined": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._platform_registry",
|
||||||
|
return_value=MagicMock(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._platform_event_outbox",
|
||||||
|
return_value=outbox,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._dataflow_trigger_dispatcher",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._workflow_trigger_dispatcher",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._search_index_coordinator",
|
||||||
|
return_value=search,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.db.session.get_database",
|
||||||
|
return_value=database,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = dispatch_platform_events.run(25)
|
||||||
|
|
||||||
|
consumer = outbox.dispatch_pending.call_args.kwargs["consumers"][0]
|
||||||
|
self.assertEqual("search.indexing.v1", consumer.consumer_id)
|
||||||
|
self.assertEqual(frozenset({"*"}), consumer.event_types)
|
||||||
|
event = PlatformEvent(type="files.file.updated", module_id="files")
|
||||||
|
delivery_key = consumer.delivery_key(event)
|
||||||
|
consumer.handler(event, delivery_key)
|
||||||
|
search.ingest_event.assert_called_once_with(
|
||||||
|
session,
|
||||||
|
event=event,
|
||||||
|
delivery_key=delivery_key,
|
||||||
|
)
|
||||||
|
search.process_changes.assert_called_once_with(session, limit=25)
|
||||||
|
self.assertEqual(1, result["search_changes"]["applied"])
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
|
||||||
def test_retention_task_uses_the_configured_terminal_window(self) -> None:
|
def test_retention_task_uses_the_configured_terminal_window(self) -> None:
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
database = MagicMock()
|
database = MagicMock()
|
||||||
|
|||||||
Reference in New Issue
Block a user