Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions nats/src/nats/js/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,20 @@ def as_dict(self) -> Dict[str, object]:
return result


@dataclass
class StreamConsumerSource(Base):
"""Pre-created push-durable consumer used for stream sourcing/mirroring (ADR-60).

Required when sourcing or mirroring from a workqueue or interest stream so
the server can drive acknowledgements via flow control rather than
auto-managing an ephemeral consumer. Both ``name`` and ``deliver_subject``
are required by the server.
"""

name: str
deliver_subject: str


@dataclass
class StreamSource(Base):
name: str
Expand All @@ -235,11 +249,13 @@ class StreamSource(Base):
filter_subject: Optional[str] = None
external: Optional[ExternalStream] = None
subject_transforms: Optional[List[SubjectTransform]] = None
consumer: Optional[StreamConsumerSource] = None

@classmethod
def from_response(cls, resp: Dict[str, Any]):
cls._convert(resp, "external", ExternalStream)
cls._convert(resp, "subject_transforms", SubjectTransform)
cls._convert(resp, "consumer", StreamConsumerSource)
cls._convert_utc_iso(resp, "opt_start_time")
return super().from_response(resp)

Expand Down Expand Up @@ -586,6 +602,10 @@ class AckPolicy(str, Enum):
NONE = "none"
ALL = "all"
EXPLICIT = "explicit"
# Required on the pre-created consumer used for sourcing/mirroring from a
# workqueue or interest stream (ADR-60). The sourcing stream, not a
# client, drives acknowledgements.
FLOW_CONTROL = "flow_control"
Comment thread
caspervonb marked this conversation as resolved.


class DeliverPolicy(str, Enum):
Expand Down
104 changes: 104 additions & 0 deletions nats/tests/test_js.py
Original file line number Diff line number Diff line change
Expand Up @@ -5501,6 +5501,110 @@ def test_opt_start_time_non_utc_timezone_preserved_on_parse(self):
)


class StreamConsumerSourceTest(unittest.TestCase):
"""Unit tests for ADR-60 sourcing-consumer config on StreamSource."""

def test_stream_source_as_dict_with_consumer(self):
src = nats.js.api.StreamSource(
name="source-stream",
consumer=nats.js.api.StreamConsumerSource(
name="durable-consumer",
deliver_subject="deliver.subj",
),
)
d = src.as_dict()
assert d["name"] == "source-stream"
assert d["consumer"] == {
"name": "durable-consumer",
"deliver_subject": "deliver.subj",
}

def test_stream_source_as_dict_without_consumer(self):
src = nats.js.api.StreamSource(name="source-stream")
d = src.as_dict()
assert "consumer" not in d

def test_stream_source_from_response_with_consumer(self):
blob = """{
"name": "source-stream",
"consumer": {"name": "durable-consumer", "deliver_subject": "deliver.subj"}
}"""
src = nats.js.api.StreamSource.from_response(json.loads(blob))
assert src.name == "source-stream"
assert isinstance(src.consumer, nats.js.api.StreamConsumerSource)
assert src.consumer.name == "durable-consumer"
assert src.consumer.deliver_subject == "deliver.subj"

def test_stream_source_from_response_without_consumer(self):
blob = '{"name": "source-stream"}'
src = nats.js.api.StreamSource.from_response(json.loads(blob))
assert src.consumer is None

def test_stream_source_consumer_round_trip(self):
original = nats.js.api.StreamSource(
name="source-stream",
consumer=nats.js.api.StreamConsumerSource(
name="durable-consumer",
deliver_subject="deliver.subj",
),
)
round_tripped = nats.js.api.StreamSource.from_response(json.loads(json.dumps(original.as_dict())))
assert round_tripped.name == original.name
assert round_tripped.consumer == original.consumer


class StreamConsumerSourceServerTest(SingleJetStreamServerTestCase):
@async_test
async def test_source_from_workqueue_with_consumer(self):
"""Source from a workqueue stream through a pre-created flow-control consumer (ADR-60)."""
nc = NATS()
await nc.connect()

server_version = nc.connected_server_version
if server_version.major == 2 and server_version.minor < 14:
pytest.skip("stream source consumer requires nats-server v2.14.0 or later")

js = nc.jetstream()
await js.add_stream(name="UP", subjects=["up"], retention=nats.js.api.RetentionPolicy.WORK_QUEUE)
await js.add_consumer(
"UP",
nats.js.api.ConsumerConfig(
durable_name="C",
deliver_subject="deliver.up",
ack_policy=nats.js.api.AckPolicy.FLOW_CONTROL,
),
)
cinfo = await js.consumer_info("UP", "C")
assert cinfo.config.ack_policy == nats.js.api.AckPolicy.FLOW_CONTROL

info = await js.add_stream(
name="DOWN",
sources=[
nats.js.api.StreamSource(
name="UP",
consumer=nats.js.api.StreamConsumerSource(name="C", deliver_subject="deliver.up"),
)
],
)
consumer = info.config.sources[0].consumer
assert isinstance(consumer, nats.js.api.StreamConsumerSource)
assert consumer.name == "C"
assert consumer.deliver_subject == "deliver.up"

for i in range(3):
await js.publish("up", f"msg-{i}".encode())

for _ in range(50):
info = await js.stream_info("DOWN")
Comment thread
caspervonb marked this conversation as resolved.
Outdated
if info.state.messages == 3:
break
await asyncio.sleep(0.1)
assert info.state.messages == 3
assert info.sources[0].error is None

await nc.close()


class PubAckBatchTest(unittest.TestCase):
"""Unit tests for ADR-50 atomic batch publish fields on PubAck."""

Expand Down
Loading