mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 20:50:58 +00:00
deploy: provision Cloud models after activation
This commit is contained in:
@@ -358,6 +358,7 @@ class DirectoryProjectionService:
|
|||||||
|
|
||||||
await self._reconcile_entitlement_snapshot_set(snapshot)
|
await self._reconcile_entitlement_snapshot_set(snapshot)
|
||||||
self._publish_runtime_execution_projection(snapshot.workspaces)
|
self._publish_runtime_execution_projection(snapshot.workspaces)
|
||||||
|
self._request_model_catalog_sync()
|
||||||
self._record_batch_cardinality(
|
self._record_batch_cardinality(
|
||||||
active_workspaces=active_workspace_count,
|
active_workspaces=active_workspace_count,
|
||||||
workspaces=workspace_count,
|
workspaces=workspace_count,
|
||||||
@@ -466,6 +467,7 @@ class DirectoryProjectionService:
|
|||||||
returned.values(),
|
returned.values(),
|
||||||
affected_workspace_uuids=requested,
|
affected_workspace_uuids=requested,
|
||||||
)
|
)
|
||||||
|
self._request_model_catalog_sync()
|
||||||
self._record_batch_cardinality(
|
self._record_batch_cardinality(
|
||||||
active_workspaces=active_workspace_count,
|
active_workspaces=active_workspace_count,
|
||||||
workspaces=workspace_count,
|
workspaces=workspace_count,
|
||||||
@@ -475,6 +477,14 @@ class DirectoryProjectionService:
|
|||||||
self._record_success()
|
self._record_success()
|
||||||
self._consumer_cursor = batch.cursor
|
self._consumer_cursor = batch.cursor
|
||||||
|
|
||||||
|
def _request_model_catalog_sync(self) -> None:
|
||||||
|
"""Wake model provisioning after a committed directory change."""
|
||||||
|
|
||||||
|
service = getattr(self.ap, 'cloud_model_catalog_service', None)
|
||||||
|
request_sync = getattr(service, 'request_sync', None)
|
||||||
|
if callable(request_sync):
|
||||||
|
request_sync()
|
||||||
|
|
||||||
def _publish_runtime_execution_projection(
|
def _publish_runtime_execution_projection(
|
||||||
self,
|
self,
|
||||||
workspaces: Iterable[DirectoryWorkspace],
|
workspaces: Iterable[DirectoryWorkspace],
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ class CloudModelCatalogSyncService:
|
|||||||
# following database reconciliation is a no-op.
|
# following database reconciliation is a no-op.
|
||||||
self._runtime_reload_pending = False
|
self._runtime_reload_pending = False
|
||||||
self._workspace_credits: dict[str, int | None] = {}
|
self._workspace_credits: dict[str, int | None] = {}
|
||||||
|
self._sync_requested = asyncio.Event()
|
||||||
|
|
||||||
def get_workspace_credits(self, workspace_uuid: str) -> int | None:
|
def get_workspace_credits(self, workspace_uuid: str) -> int | None:
|
||||||
"""Return the latest signed owner-credit projection for a Workspace."""
|
"""Return the latest signed owner-credit projection for a Workspace."""
|
||||||
@@ -159,9 +160,18 @@ class CloudModelCatalogSyncService:
|
|||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
await self.sync_once(reload_runtime=False)
|
await self.sync_once(reload_runtime=False)
|
||||||
|
|
||||||
|
def request_sync(self) -> None:
|
||||||
|
"""Wake the catalog loop after a directory Workspace change."""
|
||||||
|
|
||||||
|
self._sync_requested.set()
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(self.sync_interval_seconds)
|
try:
|
||||||
|
await asyncio.wait_for(self._sync_requested.wait(), timeout=self.sync_interval_seconds)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
self._sync_requested.clear()
|
||||||
try:
|
try:
|
||||||
await self.sync_once(reload_runtime=True)
|
await self.sync_once(reload_runtime=True)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|||||||
@@ -181,6 +181,39 @@ def _delta(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_directory_delta_requests_model_catalog_sync_after_commit(projection_context):
|
||||||
|
application, _session_factory = projection_context
|
||||||
|
request_sync = Mock()
|
||||||
|
application.cloud_model_catalog_service = SimpleNamespace(request_sync=request_sync)
|
||||||
|
event = DirectoryEvent(
|
||||||
|
cursor=2,
|
||||||
|
uuid='20000000-0000-4000-8000-000000000002',
|
||||||
|
aggregate_uuid=WORKSPACE_UUID,
|
||||||
|
event_type='directory.changed',
|
||||||
|
revision=2,
|
||||||
|
payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 2},
|
||||||
|
created_at=datetime.datetime(2026, 7, 24, 12, 30, tzinfo=datetime.UTC),
|
||||||
|
)
|
||||||
|
batch = DirectoryEventBatch(
|
||||||
|
instance_uuid=INSTANCE_UUID,
|
||||||
|
after_cursor=1,
|
||||||
|
cursor=2,
|
||||||
|
high_water_cursor=2,
|
||||||
|
events=[event],
|
||||||
|
)
|
||||||
|
service = DirectoryProjectionService(
|
||||||
|
application,
|
||||||
|
_Provider([_snapshot(1)], [batch], [_delta(workspaces=[_workspace(revision=2)])]),
|
||||||
|
INSTANCE_UUID,
|
||||||
|
)
|
||||||
|
await service.initialize()
|
||||||
|
request_sync.reset_mock()
|
||||||
|
|
||||||
|
await service.sync_once()
|
||||||
|
|
||||||
|
request_sync.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
||||||
application, session_factory = projection_context
|
application, session_factory = projection_context
|
||||||
reconcile_execution_projection = Mock()
|
reconcile_execution_projection = Mock()
|
||||||
|
|||||||
@@ -273,6 +273,94 @@ async def test_snapshot_must_cover_every_active_workspace() -> None:
|
|||||||
await service.sync_once()
|
await service.sync_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_periodic_sync_discovers_workspace_created_after_startup_cache_release(tmp_path) -> None:
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog-new-workspace.db"}')
|
||||||
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||||
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||||
|
startup_bindings = [
|
||||||
|
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_A, placement_generation=1)
|
||||||
|
]
|
||||||
|
live_bindings = [
|
||||||
|
*startup_bindings,
|
||||||
|
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_B, placement_generation=1),
|
||||||
|
]
|
||||||
|
|
||||||
|
class _WorkspaceService:
|
||||||
|
startup_released = False
|
||||||
|
|
||||||
|
async def list_active_execution_bindings(self):
|
||||||
|
return list(live_bindings if self.startup_released else startup_bindings)
|
||||||
|
|
||||||
|
def release_startup_execution_bindings(self):
|
||||||
|
self.startup_released = True
|
||||||
|
|
||||||
|
workspace_service = _WorkspaceService()
|
||||||
|
app = SimpleNamespace(
|
||||||
|
persistence_mgr=manager,
|
||||||
|
workspace_service=workspace_service,
|
||||||
|
model_mgr=SimpleNamespace(load_models_from_db=_AsyncCounter()),
|
||||||
|
logger=logging.getLogger(__name__),
|
||||||
|
)
|
||||||
|
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
await connection.execute(
|
||||||
|
sqlalchemy.insert(Workspace),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'uuid': WORKSPACE_A,
|
||||||
|
'instance_uuid': INSTANCE_UUID,
|
||||||
|
'name': 'A',
|
||||||
|
'slug': 'a',
|
||||||
|
'source': 'cloud_projection',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'uuid': WORKSPACE_B,
|
||||||
|
'instance_uuid': INSTANCE_UUID,
|
||||||
|
'name': 'B',
|
||||||
|
'slug': 'b',
|
||||||
|
'source': 'cloud_projection',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.initialize()
|
||||||
|
workspace_service.release_startup_execution_bindings()
|
||||||
|
await service.sync_once()
|
||||||
|
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
provider_b = await connection.scalar(
|
||||||
|
sqlalchemy.select(ModelProvider).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_B))
|
||||||
|
)
|
||||||
|
assert provider_b is not None
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_catalog_run_wakes_immediately_when_directory_changes() -> None:
|
||||||
|
sync_started = asyncio.Event()
|
||||||
|
|
||||||
|
class _WakeService(CloudModelCatalogSyncService):
|
||||||
|
async def sync_once(self, *, reload_runtime: bool = True):
|
||||||
|
del reload_runtime
|
||||||
|
sync_started.set()
|
||||||
|
return {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0}
|
||||||
|
|
||||||
|
app = SimpleNamespace(logger=logging.getLogger(__name__))
|
||||||
|
service = _WakeService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID, sync_interval_seconds=3600)
|
||||||
|
task = asyncio.create_task(service.run())
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
service.request_sync()
|
||||||
|
await asyncio.wait_for(sync_started.wait(), timeout=0.2)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
|
||||||
async def _async_value(value):
|
async def _async_value(value):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user