Merge remote-tracking branch 'origin/master' into docs

This commit is contained in:
Jenkins, Kenneth Alexander 2026-04-08 19:58:18 -04:00
commit 23e18cd562
No known key found for this signature in database
3 changed files with 138 additions and 72 deletions

View file

@ -63,12 +63,14 @@ Everything else is included.
## Quickstart ## Quickstart
There's no need to clone this repo, unless you want to build from source. TrustGraph is a fully containerized app that deploys as a set of Docker containers. To configure TrustGraph on the command line:
``` ```
npx @trustgraph/config npx @trustgraph/config
``` ```
TrustGraph downloads as Docker containers and can be run locally with Docker, Podman, or Minikube. The config tool will generate: The config process will generate an app config that can be run locally with Docker, Podman, or Minikube. The process will output:
- `deploy.zip` with either a `docker-compose.yaml` file for a Docker/Podman deploy or `resources.yaml` for Kubernetes - `deploy.zip` with either a `docker-compose.yaml` file for a Docker/Podman or `resources.yaml` for Kubernetes
- Deployment instructions as `INSTALLATION.md` - Deployment instructions as `INSTALLATION.md`
<p align="center"> <p align="center">
@ -76,23 +78,7 @@ TrustGraph downloads as Docker containers and can be run locally with Docker, Po
width="80%" controls></video> width="80%" controls></video>
</p> </p>
For a browser based quickstart, try the [Configuration Terminal](https://config-ui.demo.trustgraph.ai/). For a browser based configuration, try the [Configuration Terminal](https://config-ui.demo.trustgraph.ai/).
<details>
<summary>Table of Contents</summary>
<br>
- [**What is a Context Graph?**](#watch-what-is-a-context-graph)<br>
- [**Context Graphs in Action**](#watch-context-graphs-in-action)<br>
- [**Getting Started**](#getting-started-with-trustgraph)<br>
- [**Context Cores**](#context-cores)<br>
- [**Tech Stack**](#tech-stack)<br>
- [**Observability & Telemetry**](#observability--telemetry)<br>
- [**Contributing**](#contributing)<br>
- [**License**](#license)<br>
- [**Support & Community**](#support--community)<br>
</details>
## Watch What is a Context Graph? ## Watch What is a Context Graph?

View file

@ -49,6 +49,7 @@ class TestDispatcherManager:
assert manager.prefix == "api-gateway" # default prefix assert manager.prefix == "api-gateway" # default prefix
assert manager.flows == {} assert manager.flows == {}
assert manager.dispatchers == {} assert manager.dispatchers == {}
assert isinstance(manager.dispatcher_lock, asyncio.Lock)
# Verify manager was added as handler to config receiver # Verify manager was added as handler to config receiver
mock_config_receiver.add_handler.assert_called_once_with(manager) mock_config_receiver.add_handler.assert_called_once_with(manager)
@ -558,3 +559,84 @@ class TestDispatcherManager:
with pytest.raises(RuntimeError, match="Invalid kind"): with pytest.raises(RuntimeError, match="Invalid kind"):
await manager.invoke_flow_service("data", "responder", "test_flow", "invalid-kind") await manager.invoke_flow_service("data", "responder", "test_flow", "invalid-kind")
@pytest.mark.asyncio
async def test_invoke_global_service_concurrent_calls_create_single_dispatcher(self):
"""Concurrent calls for the same service must create exactly one dispatcher.
Before the fix, await dispatcher.start() yielded to the event loop and
multiple coroutines could all pass the 'key not in self.dispatchers' check
before any of them wrote the result back, creating duplicate Pulsar consumers.
"""
mock_backend = Mock()
mock_config_receiver = Mock()
manager = DispatcherManager(mock_backend, mock_config_receiver)
async def slow_start():
# Yield to the event loop so other coroutines get a chance to run,
# reproducing the window that caused the original race condition.
await asyncio.sleep(0)
with patch('trustgraph.gateway.dispatch.manager.global_dispatchers') as mock_dispatchers:
mock_dispatcher_class = Mock()
mock_dispatcher = Mock()
mock_dispatcher.start = AsyncMock(side_effect=slow_start)
mock_dispatcher.process = AsyncMock(return_value="result")
mock_dispatcher_class.return_value = mock_dispatcher
mock_dispatchers.__getitem__.return_value = mock_dispatcher_class
results = await asyncio.gather(*[
manager.invoke_global_service("data", "responder", "config")
for _ in range(5)
])
assert mock_dispatcher_class.call_count == 1, (
"Dispatcher class instantiated more than once — duplicate consumer bug"
)
assert mock_dispatcher.start.call_count == 1
assert manager.dispatchers[(None, "config")] is mock_dispatcher
assert all(r == "result" for r in results)
@pytest.mark.asyncio
async def test_invoke_flow_service_concurrent_calls_create_single_dispatcher(self):
"""Concurrent calls for the same flow+kind must create exactly one dispatcher.
invoke_flow_service has the same check-then-create pattern as
invoke_global_service and is protected by the same dispatcher_lock.
"""
mock_backend = Mock()
mock_config_receiver = Mock()
manager = DispatcherManager(mock_backend, mock_config_receiver)
manager.flows["test_flow"] = {
"interfaces": {
"agent": {
"request": "agent_request_queue",
"response": "agent_response_queue",
}
}
}
async def slow_start():
await asyncio.sleep(0)
with patch('trustgraph.gateway.dispatch.manager.request_response_dispatchers') as mock_rr_dispatchers:
mock_dispatcher_class = Mock()
mock_dispatcher = Mock()
mock_dispatcher.start = AsyncMock(side_effect=slow_start)
mock_dispatcher.process = AsyncMock(return_value="result")
mock_dispatcher_class.return_value = mock_dispatcher
mock_rr_dispatchers.__getitem__.return_value = mock_dispatcher_class
mock_rr_dispatchers.__contains__.return_value = True
results = await asyncio.gather(*[
manager.invoke_flow_service("data", "responder", "test_flow", "agent")
for _ in range(5)
])
assert mock_dispatcher_class.call_count == 1, (
"Dispatcher class instantiated more than once — duplicate consumer bug"
)
assert mock_dispatcher.start.call_count == 1
assert manager.dispatchers[("test_flow", "agent")] is mock_dispatcher
assert all(r == "result" for r in results)

View file

@ -116,6 +116,7 @@ class DispatcherManager:
self.flows = {} self.flows = {}
self.dispatchers = {} self.dispatchers = {}
self.dispatcher_lock = asyncio.Lock()
async def start_flow(self, id, flow): async def start_flow(self, id, flow):
logger.info(f"Starting flow {id}") logger.info(f"Starting flow {id}")
@ -163,30 +164,28 @@ class DispatcherManager:
key = (None, kind) key = (None, kind)
if key in self.dispatchers: if key not in self.dispatchers:
return await self.dispatchers[key].process(data, responder) async with self.dispatcher_lock:
if key not in self.dispatchers:
request_queue = None
response_queue = None
if kind in self.queue_overrides:
request_queue = self.queue_overrides[kind].get("request")
response_queue = self.queue_overrides[kind].get("response")
# Get queue overrides if specified for this service dispatcher = global_dispatchers[kind](
request_queue = None backend = self.backend,
response_queue = None timeout = 120,
if kind in self.queue_overrides: consumer = f"{self.prefix}-{kind}-request",
request_queue = self.queue_overrides[kind].get("request") subscriber = f"{self.prefix}-{kind}-request",
response_queue = self.queue_overrides[kind].get("response") request_queue = request_queue,
response_queue = response_queue,
)
dispatcher = global_dispatchers[kind]( await dispatcher.start()
backend = self.backend, self.dispatchers[key] = dispatcher
timeout = 120,
consumer = f"{self.prefix}-{kind}-request",
subscriber = f"{self.prefix}-{kind}-request",
request_queue = request_queue,
response_queue = response_queue,
)
await dispatcher.start() return await self.dispatchers[key].process(data, responder)
self.dispatchers[key] = dispatcher
return await dispatcher.process(data, responder)
def dispatch_flow_import(self): def dispatch_flow_import(self):
return self.process_flow_import return self.process_flow_import
@ -297,36 +296,35 @@ class DispatcherManager:
key = (flow, kind) key = (flow, kind)
if key in self.dispatchers: if key not in self.dispatchers:
return await self.dispatchers[key].process(data, responder) async with self.dispatcher_lock:
if key not in self.dispatchers:
intf_defs = self.flows[flow]["interfaces"]
intf_defs = self.flows[flow]["interfaces"] if kind not in intf_defs:
raise RuntimeError("This kind not supported by flow")
if kind not in intf_defs: qconfig = intf_defs[kind]
raise RuntimeError("This kind not supported by flow")
qconfig = intf_defs[kind] if kind in request_response_dispatchers:
dispatcher = request_response_dispatchers[kind](
backend = self.backend,
request_queue = qconfig["request"],
response_queue = qconfig["response"],
timeout = 120,
consumer = f"{self.prefix}-{flow}-{kind}-request",
subscriber = f"{self.prefix}-{flow}-{kind}-request",
)
elif kind in sender_dispatchers:
dispatcher = sender_dispatchers[kind](
backend = self.backend,
queue = qconfig,
)
else:
raise RuntimeError("Invalid kind")
if kind in request_response_dispatchers: await dispatcher.start()
dispatcher = request_response_dispatchers[kind]( self.dispatchers[key] = dispatcher
backend = self.backend,
request_queue = qconfig["request"],
response_queue = qconfig["response"],
timeout = 120,
consumer = f"{self.prefix}-{flow}-{kind}-request",
subscriber = f"{self.prefix}-{flow}-{kind}-request",
)
elif kind in sender_dispatchers:
dispatcher = sender_dispatchers[kind](
backend = self.backend,
queue = qconfig,
)
else:
raise RuntimeError("Invalid kind")
await dispatcher.start() return await self.dispatchers[key].process(data, responder)
self.dispatchers[key] = dispatcher
return await dispatcher.process(data, responder)