GeezerVison is showing github.com — [connectors] NATS: use async-nats 0.51.0 · feldera/feldera@2520ef2
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: Cargo.lock +10-8Lines changed: 10 additions & 8 deletions Collapse file Copy file name to clipboardExpand all lines: Cargo.toml +7-1Lines changed: 7 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -67,10 +67,11 @@ async-channel = "2.3.1" | 67 | 67 | # Its default feature set selects ring; aws-lc-rs is the same choice made | 68 | 68 | # elsewhere in this workspace. The rest of the list restates the defaults, so | 69 | 69 | # revisit it when upgrading. | 70 | | -async-nats = { version = "0.47.0", default-features = false, features = [ | | 70 | +async-nats = { version = "0.51.0", default-features = false, features = [ | 71 | 71 | "server_2_10", | 72 | 72 | "server_2_11", | 73 | 73 | "server_2_12", | | 74 | + "server_2_14", | 74 | 75 | "service", | 75 | 76 | "aws-lc-rs", | 76 | 77 | "jetstream", | @@ -104,6 +105,11 @@ aws-lc-rs = "1.18.0" | 104 | 105 | rsa = { version = "0.9.10", features = ["std"] } | 105 | 106 | rcgen = { version = "0.14.8", default-features = false, features = ["pem", "aws_lc_rs"] } | 106 | 107 | tokio-postgres-rustls = { version = "0.14.0", default-features = false, features = ["aws-lc-rs"] } | | 108 | +# Pulled in by async-nats for ws:// server URLs. Its default hashes the | | 109 | +# websocket handshake with sha1_smol; the feature keeps it on aws-lc-rs. | | 110 | +# Keep the version in sync with the one async-nats depends on, or the | | 111 | +# lockfile ends up with two copies and only async-nats's one is used. | | 112 | +tokio-websockets = { version = "0.13.3", default-features = false, features = ["aws_lc_rs"] } | 107 | 113 | base64 = "0.22.1" | 108 | 114 | base58 = "0.2.0" | 109 | 115 | binrw = "0.15.2" | |
Collapse file Copy file name to clipboardExpand all lines: crates/connectors/Cargo.toml +3-1Lines changed: 3 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -113,6 +113,8 @@ awc = { workspace = true, features = [ | 113 | 113 | "rustls-0_23-webpki-roots", | 114 | 114 | ] } | 115 | 115 | async-nats = { workspace = true } | | 116 | +# Only here to select aws-lc-rs for async-nats's websocket handshake. | | 117 | +tokio-websockets = { workspace = true } | 116 | 118 | async-stream = { workspace = true } | 117 | 119 | data-encoding = { workspace = true } | 118 | 120 | anyhow = { workspace = true, features = ["backtrace"] } | @@ -264,7 +266,7 @@ z85 = { workspace = true, optional = true } | 264 | 266 | etl = { workspace = true, optional = true } | 265 | 267 |
| 266 | 268 | [package.metadata.cargo-machete] | 267 | | -ignored = ["aws-sigv4", "deltalake-catalog-unity"] | | 269 | +ignored = ["aws-sigv4", "deltalake-catalog-unity", "tokio-websockets"] | 268 | 270 |
| 269 | 271 | [target.'cfg(target_os = "linux")'.dependencies] | 270 | 272 | # Optional so the SQL test fixture (sql-to-dbsp-compiler/temp), which builds | |
Collapse file Copy file name to clipboardExpand all lines: crates/connectors/src/transport/nats/input.rs +76-18Lines changed: 76 additions & 18 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -501,7 +501,7 @@ impl NatsReader { | 501 | 501 |
| 502 | 502 | // A replay consumer is single-use: whether the replay | 503 | 503 | // succeeded or not, the next attempt creates a fresh one. | 504 | | - delete_nats_consumer(&jetstream, &stream_ctx, &consumer_name).await; | | 504 | + delete_consumer_family(&jetstream, &stream_ctx, &consumer_name).await; | 505 | 505 |
| 506 | 506 | result.map_err(|error| { | 507 | 507 | error.with_context(format!( | @@ -929,9 +929,9 @@ impl NatsReader { | 929 | 929 | /// Every consumer gets this prefix plus a unique suffix (see | 930 | 930 | /// [`create_nats_consumer`]), so consumers are always named by us rather than | 931 | 931 | /// by the server. Server-assigned names are opaque, which makes it impossible | 932 | | -/// to attribute a consumer to a pipeline from the NATS side and, worse, keeps | 933 | | -/// the ordered consumer's self-recreation from ever deleting the consumer it | 934 | | -/// replaces (it deletes by the client-side name, which then never matches). | | 932 | +/// to attribute a consumer to a pipeline from the NATS side. The client | | 933 | +/// library names its own recreations of a consumer after it, see | | 934 | +/// [`list_consumer_family`]. | 935 | 935 | fn consumer_name_prefix(consumer_config: &cfg::ConsumerConfig, table_name: &str) -> String { | 936 | 936 | let base = consumer_config | 937 | 937 | .name | @@ -1096,6 +1096,71 @@ async fn delete_nats_consumer( | 1096 | 1096 | } | 1097 | 1097 | } | 1098 | 1098 |
| | 1099 | +/// Names the consumers on the stream that descend from `consumer_name`: the | | 1100 | +/// consumer itself and the `{consumer_name}_{n}` ones the client library | | 1101 | +/// creates when it recreates an ordered consumer after a gap, a lost | | 1102 | +/// heartbeat or an external deletion. The library deletes the consumer it | | 1103 | +/// replaces, so at most one of them normally exists. The unique suffix of | | 1104 | +/// our own names (see [`create_nats_consumer`]) keeps one connector's | | 1105 | +/// consumers out of another's family. | | 1106 | +async fn list_consumer_family( | | 1107 | + jetstream: &jetstream::Context, | | 1108 | + stream_name: &str, | | 1109 | + consumer_name: &str, | | 1110 | +) -> AnyResult<Vec<String>> { | | 1111 | + use futures::TryStreamExt; | | 1112 | + let stream = jetstream.get_stream_no_info(stream_name).await?; | | 1113 | + let recreated_prefix = format!("{consumer_name}_"); | | 1114 | + let mut names = stream.consumer_names(); | | 1115 | + let mut family = Vec::new(); | | 1116 | + while let Some(name) = names.try_next().await? { | | 1117 | + if name == consumer_name || name.starts_with(&recreated_prefix) { | | 1118 | + family.push(name); | | 1119 | + } | | 1120 | + } | | 1121 | + Ok(family) | | 1122 | +} | | 1123 | + | | 1124 | +/// Deletes the consumer and every recreation of it, see | | 1125 | +/// [`list_consumer_family`]. Falls back to deleting `consumer_name` alone | | 1126 | +/// when the listing fails. | | 1127 | +async fn delete_consumer_family( | | 1128 | + jetstream: &jetstream::Context, | | 1129 | + stream_ctx: &StreamContext, | | 1130 | + consumer_name: &str, | | 1131 | +) { | | 1132 | + let stream_name = stream_ctx.stream_name.as_str(); | | 1133 | + match tokio::time::timeout( | | 1134 | + CONSUMER_DELETE_TIMEOUT, | | 1135 | + list_consumer_family(jetstream, stream_name, consumer_name), | | 1136 | + ) | | 1137 | + .await | | 1138 | + { | | 1139 | + Ok(Ok(family)) => { | | 1140 | + if family.is_empty() { | | 1141 | + debug!( | | 1142 | + "No consumer named '{consumer_name}' or recreated from it is left on stream '{stream_name}'" | | 1143 | + ); | | 1144 | + } | | 1145 | + for name in family { | | 1146 | + delete_nats_consumer(jetstream, stream_ctx, &name).await; | | 1147 | + } | | 1148 | + } | | 1149 | + Ok(Err(error)) => { | | 1150 | + warn!( | | 1151 | + "Failed to list the consumers of stream '{stream_name}', deleting only '{consumer_name}': {error:#}" | | 1152 | + ); | | 1153 | + delete_nats_consumer(jetstream, stream_ctx, consumer_name).await; | | 1154 | + } | | 1155 | + Err(_) => { | | 1156 | + warn!( | | 1157 | + "Timed out after {CONSUMER_DELETE_TIMEOUT:?} listing the consumers of stream '{stream_name}', deleting only '{consumer_name}'" | | 1158 | + ); | | 1159 | + delete_nats_consumer(jetstream, stream_ctx, consumer_name).await; | | 1160 | + } | | 1161 | + } | | 1162 | +} | | 1163 | + | 1099 | 1164 | /// Grace period before re-checking whether a consumer we just deleted has | 1100 | 1165 | /// reappeared. | 1101 | 1166 | const DELAYED_CREATE_RECHECK_DELAY: Duration = Duration::from_secs(2); | @@ -1108,7 +1173,8 @@ const DELAYED_CREATE_RECHECK_DELAY: Duration = Duration::from_secs(2); | 1108 | 1173 | /// apply the create after the delete, leaving an orphan that nobody will | 1109 | 1174 | /// ever read and that scans the stream on the server's behalf. A delete | 1110 | 1175 | /// sent immediately after would race the same reordering, so this instead | 1111 | | -/// waits out a grace period, then deletes the consumer again if it exists. | | 1176 | +/// waits out a grace period, then deletes the consumer and any recreation | | 1177 | +/// of it (see [`list_consumer_family`]) that exists by then. | 1112 | 1178 | /// | 1113 | 1179 | /// Spawned rather than awaited: the caller (a create path, or a pause | 1114 | 1180 | /// tearing down a reader) should not block on this best-effort cleanup. | @@ -1122,16 +1188,7 @@ fn clean_up_delayed_create( | 1122 | 1188 | let consumer_name = consumer_name.to_string(); | 1123 | 1189 | tokio::spawn(async move { | 1124 | 1190 | tokio::time::sleep(DELAYED_CREATE_RECHECK_DELAY).await; | 1125 | | - let reappeared = jetstream | 1126 | | - .get_consumer_from_stream::<nats_consumer::Config, _, _>( | 1127 | | - &consumer_name, | 1128 | | - &stream_ctx.stream_name, | 1129 | | - ) | 1130 | | - .await | 1131 | | - .is_ok(); | 1132 | | - if reappeared { | 1133 | | - delete_nats_consumer(&jetstream, &stream_ctx, &consumer_name).await; | 1134 | | - } | | 1191 | + delete_consumer_family(&jetstream, &stream_ctx, &consumer_name).await; | 1135 | 1192 | }); | 1136 | 1193 | } | 1137 | 1194 |
| @@ -1526,16 +1583,17 @@ impl Canceller { | 1526 | 1583 | // The reader task is gone, but the client library may have already | 1527 | 1584 | // sent its own recreate of this consumer (on a detected gap or lost | 1528 | 1585 | // heartbeat) before cancellation reached it. That recreate can still | 1529 | | - // land after our delete below, the same reordering | 1530 | | - // `clean_up_delayed_create` exists for, so check for it too. | | 1586 | + // land after the cleanup below has looked for it, the same | | 1587 | + // reordering `clean_up_delayed_create` exists for, so check for it | | 1588 | + // too. | 1531 | 1589 | let Canceller { | 1532 | 1590 | jetstream, | 1533 | 1591 | stream_ctx, | 1534 | 1592 | consumer_name, | 1535 | 1593 | .. | 1536 | 1594 | } = self; | 1537 | 1595 | async move { | 1538 | | - delete_nats_consumer(&jetstream, &stream_ctx, &consumer_name).await; | | 1596 | + delete_consumer_family(&jetstream, &stream_ctx, &consumer_name).await; | 1539 | 1597 | clean_up_delayed_create(&jetstream, &stream_ctx, &consumer_name); | 1540 | 1598 | } | 1541 | 1599 | } | |
Collapse file Copy file name to clipboardExpand all lines: crates/connectors/src/transport/nats/input/test/hardening_tests.rs +36Lines changed: 36 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -512,6 +512,42 @@ pub fn deleted_positioned_consumer_does_not_reingest(engine: &dyn ConnectorTestE | 512 | 512 | .unwrap(); | 513 | 513 | } | 514 | 514 |
| | 515 | +/// A consumer the client library recreated must not outlive a pause. | | 516 | +/// | | 517 | +/// The library names a recreated ordered consumer `{name}_{n}` and deletes | | 518 | +/// the one it replaces, so the connector must delete the recreated consumer | | 519 | +/// rather than the name it chose itself. | | 520 | +#[engine_test(services(Nats))] | | 521 | +pub fn library_recreated_consumer_is_deleted_on_pause(engine: &dyn ConnectorTestEngine) { | | 522 | + use NatsMockAction::*; | | 523 | + run_nats_mock_test( | | 524 | + engine, | | 525 | + basic_nats_config, | | 526 | + &[ | | 527 | + StartNats, | | 528 | + CreateStream, | | 529 | + Publish(2), | | 530 | + CreatePipeline, | | 531 | + Extend, | | 532 | + WaitForRecords(2), | | 533 | + DeleteConsumers, | | 534 | + // The library notices the deletion and recreates the consumer. | | 535 | + WaitForConsumerCount { | | 536 | + count: 1, | | 537 | + timeout: CONSUMER_COUNT_TIMEOUT, | | 538 | + }, | | 539 | + Pause, | | 540 | + WaitForConsumerCount { | | 541 | + count: 0, | | 542 | + timeout: CONSUMER_COUNT_TIMEOUT, | | 543 | + }, | | 544 | + AssertConsumerCountersMatchServer, | | 545 | + Disconnect, | | 546 | + ], | | 547 | + ) | | 548 | + .unwrap(); | | 549 | +} | | 550 | + | 515 | 551 | /// The consumer counters must agree with the server when a create times out | 516 | 552 | /// but the server created the consumer anyway. | 517 | 553 | /// | |
Collapse file Copy file name to clipboardExpand all lines: crates/connectors/src/transport/nats/input/test/jetstream_proxy.rs +11-2Lines changed: 11 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -36,8 +36,9 @@ pub struct ProxyFaults { | 36 | 36 | /// Drops the server's reply to the nth consumer create. The server still | 37 | 37 | /// creates the consumer. | 38 | 38 | pub drop_create_reply: Option<usize>, | 39 | | - /// Holds the nth consumer create until a delete of the same consumer has | 40 | | - /// reached the server, then forwards the create. | | 39 | + /// Holds the nth consumer create until a delete of the same consumer, or | | 40 | + /// a listing of the stream's consumers, has reached the server, then | | 41 | + /// forwards the create. | 41 | 42 | pub hold_create_until_delete: Option<usize>, | 42 | 43 | /// Holds every consumer delete for this long before forwarding it. | 43 | 44 | pub delay_deletes: Option<Duration>, | @@ -336,6 +337,14 @@ async fn proxy_connection( | 336 | 337 | state.dropped_replies.insert(reply.to_string()); | 337 | 338 | } | 338 | 339 | to_server.push(frame.bytes()); | | 340 | + // The connector lists the stream's consumers before | | 341 | + // deleting; a held create landing right after that | | 342 | + // listing is the reordering the hold stands for. | | 343 | + if subject.starts_with("$JS.API.CONSUMER.NAMES.") | | 344 | + && let Some((_, create)) = state.held_create.take() | | 345 | + { | | 346 | + to_server.push(create); | | 347 | + } | 339 | 348 | } | 340 | 349 | } | 341 | 350 | if let Some(gate) = request_gate { | |
Collapse file Copy file name to clipboardExpand all lines: crates/connectors/src/transport/nats/output.rs +7-6Lines changed: 7 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -125,16 +125,17 @@ enum PublishFailure { | 125 | 125 |
| 126 | 126 | /// Classifies an ack error from `async-nats`. | 127 | 127 | /// | 128 | | -/// `Other` carries server errors the client does not map to a kind (such as | 129 | | -/// an exceeded message size, which the pre-publish check keeps off this | 130 | | -/// path): retried, because a transient server-side condition also lands | 131 | | -/// here, and the retry loop logs the error text so a permanent one is | 132 | | -/// visible in the logs. | | 128 | +/// `MaxPayloadExceeded` is the client's own size check, which the | | 129 | +/// pre-publish check keeps off this path. `Other` carries server errors the | | 130 | +/// client does not map to a kind: retried, because a transient server-side | | 131 | +/// condition also lands here, and the retry loop logs the error text so a | | 132 | +/// permanent one is visible in the logs. | 133 | 133 | fn classify(error: jetstream::context::PublishError) -> PublishFailure { | 134 | 134 | match error.kind() { | 135 | 135 | PublishErrorKind::StreamNotFound | 136 | 136 | | PublishErrorKind::WrongLastMessageId | 137 | | - | PublishErrorKind::WrongLastSequence => PublishFailure::Permanent(error.into()), | | 137 | + | PublishErrorKind::WrongLastSequence | | 138 | + | PublishErrorKind::MaxPayloadExceeded => PublishFailure::Permanent(error.into()), | 138 | 139 | PublishErrorKind::TimedOut | 139 | 140 | | PublishErrorKind::BrokenPipe | 140 | 141 | | PublishErrorKind::MaxAckPending | |
Collapse file Copy file name to clipboardExpand all lines: docs.feldera.com/docs/changelog.md +8Lines changed: 8 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -26,6 +26,14 @@ Source edition can be found on github. | 26 | 26 | counts, and queue wait time. See | 27 | 27 | [Kafka output delivery and metrics](/connectors/sinks/kafka#delivery-retries-and-backpressure). | 28 | 28 |
| | 29 | +- NATS input connector: the client library is updated to async-nats 0.51.0, | | 30 | + which fixes spurious missing-heartbeat errors while messages are buffered | | 31 | + and pull consumers looping on `409 Exceeded MaxRequest*`. The `max_batch`, | | 32 | + `max_bytes` and `max_expiry` consumer settings now reach the server, where | | 33 | + earlier releases dropped them. A consumer that the library recreates is | | 34 | + named `<prefix>_<uuid>_<n>`, and the connector deletes it together with its | | 35 | + own. See [consumer lifecycle](/connectors/sources/nats#consumer-lifecycle). | | 36 | + | 29 | 37 | ## v0.371.0 | 30 | 38 |
| 31 | 39 | - Delta Lake input connector: `uc://` tables can use Databricks workload | |
Collapse file Copy file name to clipboardExpand all lines: docs.feldera.com/docs/connectors/sources/nats.md +9-5Lines changed: 9 additions & 5 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -50,8 +50,8 @@ The connector configuration consists of three main sections: | 50 | 50 | | `deliver_policy` | variant | Yes | Starting point for reading from the stream (see [Deliver Policy](#deliver-policy)) | | 51 | 51 | | `max_waiting` | integer | No | Maximum outstanding pull requests. Default: 0 | | 52 | 52 | | `metadata` | map (string → string) | No | Consumer metadata key-value pairs | | 53 | | -| `max_batch` | integer | No | Maximum messages per batch | | 54 | | -| `max_bytes` | integer | No | Maximum bytes per batch | | | 53 | +| `max_batch` | integer | No | Maximum messages per pull request. The server rejects larger pull requests, and the connector sizes its own accordingly | | | 54 | +| `max_bytes` | integer | No | Maximum bytes per pull request | | 55 | 55 | | `max_expiry` | duration | No | Maximum time a pull request stays parked on the server, for example `30s`. Replaces the deprecated `max_expires`. | | 56 | 56 |
| 57 | 57 | #### Deliver Policy | @@ -145,14 +145,18 @@ or the table name if none is set. Characters that JetStream does not allow in | 145 | 145 | consumer names are replaced with `_`. The unique suffix prevents "consumer | 146 | 146 | already exists" errors on quick restarts, and the prefix identifies which | 147 | 147 | connector owns a consumer when you inspect the stream with `nats consumer ls`. | 148 | | -For example, to list the consumers of a connector whose prefix is `orders`: | | 148 | +When the client library recreates a consumer after a gap, a lost heartbeat, or | | 149 | +an external deletion, the replacement is named `<prefix>_<uuid>_<n>` and the | | 150 | +replaced consumer is deleted. For example, to list the consumers of a | | 151 | +connector whose prefix is `orders`: | 149 | 152 |
| 150 | 153 | ```bash | 151 | 154 | nats consumer ls <stream> --names | grep '^orders_' | 152 | 155 | ``` | 153 | 156 |
| 154 | | -The connector deletes its consumer when it pauses, stops, abandons a failed | 155 | | -reader before retrying, or finishes a replay. It also deletes the consumer | | 157 | +The connector deletes its consumer, and any replacement the client library | | 158 | +made for it, when it pauses, stops, abandons a failed reader before retrying, | | 159 | +or finishes a replay. It also deletes the consumer | 156 | 160 | after a create request that timed out, because an overloaded server may still | 157 | 161 | create the consumer after the client has given up. Deletion is best-effort: if | 158 | 162 | the server cannot be reached, the consumer expires on its own after 30 seconds | |
|