From 27cad3f6836662d85f6b4f1fcb25c116b0c58fd7 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 17 Oct 2026 11:27:09 +0300 Subject: [PATCH] fix(schema): keep a default constraint wait inside the call deadline With no wait named, create_constraint() sent none, so the server waited by its own default: 60 s on 0.7.2 and tunable by the operator, while the client's deadline stayed at its 30 s timeout. A unique or node-key build taking between the two ended the call with DEADLINE_EXCEEDED although the server went on to create the constraint. The client cannot see the server's default, so it always names the wait: the given one, or its own timeout when none is given, and extends the call's deadline by it. Regression test: test_default_wait_ends_before_the_deadline. Closes #143 --- coordinode/coordinode/client.py | 20 +++++++++++--------- tests/unit/test_schema_crud.py | 19 +++++++++++++++++-- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/coordinode/coordinode/client.py b/coordinode/coordinode/client.py index 3c08685..68bf865 100644 --- a/coordinode/coordinode/client.py +++ b/coordinode/coordinode/client.py @@ -2011,9 +2011,10 @@ async def create_constraint( returned with its running build, so a retry after a lost reply does not start a second build. wait: Seconds to wait for the build of the owned index. ``None``: - the server's default. ``0``: return once the constraint and its - build are admitted. The call's deadline is extended by it, so - the deadline does not expire while the server is still waiting. + as long as the client's timeout. ``0``: return once the + constraint and its build are admitted. The call's deadline is + extended by it, so the deadline does not expire while the + server is still waiting. """ from coordinode._proto.coordinode.v2.graph.schema_pb2 import ( # type: ignore[import] ConstraintKind, @@ -2043,12 +2044,13 @@ async def create_constraint( ) if property_type is not None: req.property_type.CopyFrom(self._property_type_proto(property_type, "the constraint")) - timeout = self._timeout - if wait is not None: - # A zero wait is sent too: absent takes the server's default. - req.wait.FromTimedelta(datetime.timedelta(seconds=wait)) - timeout += wait - created = await self._schema_stub.CreateConstraint(req, timeout=timeout) + # Always sent: an absent wait takes the server's default, which this + # client cannot see and may exceed its deadline. Without one named, + # the server waits as long as this client's timeout. + if wait is None: + wait = self._timeout + req.wait.FromTimedelta(datetime.timedelta(seconds=wait)) + created = await self._schema_stub.CreateConstraint(req, timeout=self._timeout + wait) return ConstraintInfo(created) async def drop_constraint(self, name: str, *, if_exists: bool = False) -> None: diff --git a/tests/unit/test_schema_crud.py b/tests/unit/test_schema_crud.py index bb33ef5..3dffa5f 100644 --- a/tests/unit/test_schema_crud.py +++ b/tests/unit/test_schema_crud.py @@ -411,8 +411,23 @@ async def _inner() -> None: assert (sent.name, sent.label, list(sent.properties)) == ("l_key", "L", ["a", "b"]) assert sent.kind == schema_pb2.CONSTRAINT_KIND_NODE_KEY and sent.if_not_exists assert not sent.HasField("property_type") - # No wait named: the server's own default applies, so none is sent. - assert not sent.HasField("wait") + + asyncio.run(_inner()) + + def test_default_wait_ends_before_the_deadline(self): + # With no wait named, the server's own default applies (60 s on + # 0.7.2, operator-tunable), longer than the client's 30 s timeout: a + # build between the two ended the call with DEADLINE_EXCEEDED while + # the server went on to create the constraint. The client's timeout + # is sent as the wait instead, and the deadline covers it. + async def _inner() -> None: + client = self._client() + await client.create_constraint("L", "email", "unique") + call = client._schema_stub.CreateConstraint.call_args + sent = call.args[0] + assert sent.HasField("wait") + assert sent.wait.ToTimedelta().total_seconds() == pytest.approx(client._timeout) + assert call.kwargs["timeout"] > client._timeout asyncio.run(_inner())