Admission gate for a rename: RenameGate in flow/rename.py, installed by main.AdmissionGate inside CORS, turns mutating API requests away with 503 renaming and Retry-After while closed and drains those admitted without a limit, abortable; affected flows' hooks get 503 or socket close 1013, reads, sign-in, assistant, workers, artifacts and rename routes pass; the run cache misses while the gate is closed; trigger_node lets EngineHeld through instead of reporting a node failure
This commit is contained in:
@@ -95,6 +95,8 @@ def test_a_run_cache_finds_what_an_earlier_run_recorded(tmp_path):
|
||||
assert cache.lookup(collected) is None
|
||||
assert cache.lookup("never-seen") is None
|
||||
assert cache.lookup("") is None
|
||||
# A rename may have moved some of a row's names and not yet the others.
|
||||
assert RunCache(store, renaming=lambda: True).lookup(plain) is None
|
||||
|
||||
|
||||
def test_what_may_be_stored_as_a_cache_entry():
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""The admission gate a rename closes (section 2.1 of
|
||||
docs/private/id_refac_rename.md)."""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.routing import Route, WebSocketRoute
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.flow.rename import RenameGate
|
||||
from fluksio.main import AdmissionGate, app
|
||||
from fluksio.mcp import server
|
||||
|
||||
API = settings.API_V1_STR
|
||||
|
||||
|
||||
def _gated(
|
||||
handler_gate: threading.Event | None = None,
|
||||
) -> tuple[TestClient, RenameGate]:
|
||||
"""A bare app behind a gate of its own. A mutation waits for
|
||||
*handler_gate*, when given, so it can be caught while admitted."""
|
||||
|
||||
async def answer(request: Request) -> PlainTextResponse:
|
||||
if handler_gate is not None and request.method == "POST":
|
||||
await asyncio.to_thread(handler_gate.wait)
|
||||
return PlainTextResponse("ok")
|
||||
|
||||
async def socket(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
await websocket.send_text("ok")
|
||||
await websocket.close()
|
||||
|
||||
methods = ["GET", "POST", "PUT", "DELETE"]
|
||||
inner = Starlette(
|
||||
routes=[
|
||||
Route("/{path:path}", answer, methods=methods),
|
||||
WebSocketRoute("/hooks/{flow}/ws", socket),
|
||||
]
|
||||
)
|
||||
gate = RenameGate()
|
||||
return TestClient(AdmissionGate(inner, gate)), gate
|
||||
|
||||
|
||||
def _close(gate: RenameGate, affected: list[str] | None = None) -> None:
|
||||
assert gate.close("op-1", affected or [], threading.Event())
|
||||
|
||||
|
||||
def test_a_mutation_is_turned_away_while_closed_and_reads_pass():
|
||||
client, gate = _gated()
|
||||
_close(gate)
|
||||
|
||||
refused = client.post(f"{API}/flows/f_x/publish")
|
||||
assert refused.status_code == 503
|
||||
assert refused.headers["retry-after"] == "5"
|
||||
assert refused.json()["code"] == "renaming"
|
||||
assert refused.json()["op"] == "op-1"
|
||||
for method in ("put", "delete"):
|
||||
assert getattr(client, method)(f"{API}/dashboards/d_x").status_code == 503
|
||||
assert client.post(f"{API}/messages/heat.temp").status_code == 503
|
||||
assert client.post(f"{API}/runs/flows/f_x").status_code == 503
|
||||
|
||||
assert client.get(f"{API}/flows/f_x").status_code == 200
|
||||
for path in (
|
||||
"/login/access-token",
|
||||
"/users/me",
|
||||
"/flows/ws-ticket",
|
||||
"/ai/chat",
|
||||
"/alerts/webpush/subscriptions",
|
||||
"/artifacts",
|
||||
"/flows/f_x/rename/plan",
|
||||
"/dashboards/d_x/rename",
|
||||
):
|
||||
assert client.post(f"{API}{path}").status_code == 200, path
|
||||
|
||||
gate.open()
|
||||
assert client.post(f"{API}/flows/f_x/publish").status_code == 200
|
||||
|
||||
|
||||
def test_an_admitted_request_is_drained_before_close_returns():
|
||||
release = threading.Event()
|
||||
client, gate = _gated(release)
|
||||
answered: list[int] = []
|
||||
request = threading.Thread(
|
||||
target=lambda: answered.append(client.post(f"{API}/flows/").status_code)
|
||||
)
|
||||
request.start()
|
||||
while not gate._admitted:
|
||||
time.sleep(0.01)
|
||||
|
||||
heard: list[list[str]] = []
|
||||
closed = threading.Thread(
|
||||
target=lambda: gate.close("op-1", [], threading.Event(), heard.append)
|
||||
)
|
||||
closed.start()
|
||||
closed.join(0.3)
|
||||
assert closed.is_alive()
|
||||
assert heard == [[f"POST {API}/flows/"]]
|
||||
# Turned away meanwhile, while the one inside finishes.
|
||||
assert client.post(f"{API}/flows/").status_code == 503
|
||||
|
||||
release.set()
|
||||
closed.join(5)
|
||||
request.join(5)
|
||||
assert not closed.is_alive()
|
||||
assert answered == [200]
|
||||
assert gate.closed
|
||||
|
||||
|
||||
def test_an_aborted_drain_reopens_the_gate():
|
||||
release = threading.Event()
|
||||
client, gate = _gated(release)
|
||||
request = threading.Thread(target=lambda: client.post(f"{API}/flows/"))
|
||||
request.start()
|
||||
while not gate._admitted:
|
||||
time.sleep(0.01)
|
||||
|
||||
abort = threading.Event()
|
||||
result: list[bool] = []
|
||||
closed = threading.Thread(
|
||||
target=lambda: result.append(gate.close("op-1", [], abort))
|
||||
)
|
||||
closed.start()
|
||||
abort.set()
|
||||
closed.join(5)
|
||||
assert result == [False]
|
||||
assert not gate.closed
|
||||
release.set()
|
||||
request.join(5)
|
||||
assert client.post(f"{API}/flows/").status_code == 200
|
||||
|
||||
|
||||
def test_only_the_hooks_of_affected_flows_are_turned_away():
|
||||
client, gate = _gated()
|
||||
_close(gate, ["f_a"])
|
||||
|
||||
assert client.get("/hooks/f_a/in").status_code == 503
|
||||
assert client.post("/hooks/f_a/in").status_code == 503
|
||||
assert client.post("/hooks/f_b/in").status_code == 200
|
||||
with pytest.raises(WebSocketDisconnect) as closed:
|
||||
with client.websocket_connect("/hooks/f_a/ws") as socket:
|
||||
socket.receive_text()
|
||||
assert closed.value.code == 1013
|
||||
with client.websocket_connect("/hooks/f_b/ws") as socket:
|
||||
assert socket.receive_text() == "ok"
|
||||
|
||||
gate.open()
|
||||
assert client.post("/hooks/f_a/in").status_code == 200
|
||||
|
||||
|
||||
def test_the_app_is_gated(client: TestClient, superuser_token_headers):
|
||||
gate: RenameGate = app.state.rename_gate
|
||||
_close(gate, ["f_x"])
|
||||
try:
|
||||
assert client.get(f"{API}/flows/", headers=superuser_token_headers).is_success
|
||||
made = client.post(
|
||||
f"{API}/flows/", headers=superuser_token_headers, json={"name": "gated"}
|
||||
)
|
||||
assert made.status_code == 503
|
||||
assert client.post(
|
||||
f"{API}/login/access-token",
|
||||
data={
|
||||
"username": settings.FIRST_SUPERUSER,
|
||||
"password": settings.FIRST_SUPERUSER_PASSWORD,
|
||||
},
|
||||
).is_success
|
||||
|
||||
async def tool_call() -> object:
|
||||
previous = server._client
|
||||
server.set_client(None)
|
||||
server.connect(app)
|
||||
token = server.caller_authorization.set(
|
||||
superuser_token_headers["Authorization"]
|
||||
)
|
||||
try:
|
||||
return await server.create_flow({"name": "gated"})
|
||||
finally:
|
||||
server.caller_authorization.reset(token)
|
||||
await server.aclose()
|
||||
server.set_client(previous)
|
||||
|
||||
assert asyncio.run(tool_call()) == {
|
||||
"error": "A rename is in progress; try again shortly",
|
||||
"status": 503,
|
||||
}
|
||||
finally:
|
||||
gate.open()
|
||||
Reference in New Issue
Block a user