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())