package kafka-eio
sectionYPositions = computeSectionYPositions($el), 10)"
x-init="setTimeout(() => sectionYPositions = computeSectionYPositions($el), 10)"
>
Eio-native Kafka client for OCaml 5, built on librdkafka
Install
dune-project
Dependency
Authors
Maintainers
Sources
v0.1.1.tar.gz
md5=88c2b9c6d263a1eb114cc5d1b570288a
sha512=44f5395fcc58b8d4303bc0b36113a17193da3a1925125cb0742fabeb96f32eb6b243c98d2fde283b6c95872ff36ddbf96de6e98b7612799e2aefeb2f85a1db95
doc/src/kafka-eio.consumer/kafka_consumer.ml.html
Source file kafka_consumer.ml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491type 'e handler_result = | Continue | Stop | Error of 'e type offset_reset = | Earliest | Latest type config = { brokers : string list; group_id : string; topics : string list; offset_reset : offset_reset; auto_commit : bool; security : Kafka_security.t; properties : (string * string) list; } type message = { topic : string; partition : int32; offset : int64; key : bytes option; value : bytes option; (** [None] is a Kafka tombstone, distinct from [Some Bytes.empty] *) timestamp : int64 option; headers : (string * string option) list; (** [None] value is distinct from [Some ""] *) } (* Same fd representation used by kafka_producer.ml's wake pipe. *) external int_of_fd : Unix.file_descr -> int = "%identity" external fd_of_int : int -> Unix.file_descr = "%identity" type t = { handle : Kafka_raw.kafka_handle; config : config; stream : message Eio.Stream.t; closed : bool Atomic.t; (* Eio_unix.pipe ties fd lifetime to the switch; close releases these early. *) wake_source : Eio_unix.source_ty Eio.Std.r; wake_sink : Eio_unix.sink_ty Eio.Std.r; (* Write end of the wake pipe; close writes here to unblock single_read. *) wake_fd : int Atomic.t; poll_exited : unit Eio.Promise.t; poll_exit_r : unit Eio.Promise.u; (* Last explicitly-committed offset per partition, updated by every successful commit_message call. commit_all (when auto_commit = false) reads this instead of asking librdkafka to commit "the current assignment" — rd_kafka_commit(rk, NULL, ...) uses each partition's current fetch position, not what was actually processed, which is the same bug class as #3/#17 fixed earlier for transactions, just reached through commit_all instead. *) last_processed : (string * int32, int64) Hashtbl.t; } let err i = Result.error (Kafka_error.of_int i) let default_on_warning msg = Printf.eprintf "kafka-eio: %s\n%!" msg let conf_of_config (cfg : config) : (Kafka_raw.kafka_conf, string) result = let ( let* ) = Result.bind in let conf = Kafka_raw.conf_new () in let set k v = Kafka_raw.conf_set conf k v |> Result.map_error (fun s -> "kafka conf " ^ k ^ ": " ^ s) in let* () = set "bootstrap.servers" (String.concat "," cfg.brokers) in let* () = set "group.id" cfg.group_id in let* () = set "auto.offset.reset" (match cfg.offset_reset with Earliest -> "earliest" | Latest -> "latest") in let* () = set "enable.auto.commit" (if cfg.auto_commit then "true" else "false") in (* librdkafka 2.x changed the default assignment strategy to cooperative-sticky, which requires a rebalance_cb to drive the multi-round protocol. Without one the rebalance never completes and the consumer never gets assigned partitions. Pinning to eager rebalancing (range,roundrobin) preserves the callback-free subscribe() → poll() → assignment_count > 0 invariant our poll_fiber relies on. *) let* () = set "partition.assignment.strategy" "range,roundrobin" in let* () = Kafka_security.apply conf cfg.security in (* Applied after every typed/security default above, so advanced users can override or extend with any librdkafka key this module has no typed field for (client.id, max.poll.interval.ms, custom SASL mechanisms, ...) without waiting on a new config field. *) let* () = List.fold_left (fun acc (k, v) -> let* () = acc in set k v) (Ok ()) cfg.properties in Ok conf let tuple_to_message (topic, partition, offset, key, value, timestamp, headers) = { topic; partition; offset; key; value; timestamp; headers } (* Eio fibers must not sit in blocking C polls. Like the producer, watch a librdkafka queue with an fd and drain it with timeout 0 after wakeup. *) let poll_fiber t sw ~on_ready ~on_poll_error = let wake_source = t.wake_source and wake_sink = t.wake_sink in let write_fd_int = Eio_unix.Fd.use_exn "kafka_consumer_queue_wake_fd" (Eio_unix.Resource.fd wake_sink) int_of_fd in Atomic.set t.wake_fd write_fd_int; Kafka_raw.consumer_queue_events_enable t.handle write_fd_int; (* Daemon: switch release hooks run close, and close is what stops us. *) Eio.Fiber.fork_daemon ~sw (fun () -> let wake_buf = Cstruct.create 4096 in let notified = ref false in let prev_assignment = ref None in (* Rebalance transitions also wake the consumer queue, so assignment can be tracked per wake instead of by fixed-period polling. *) let rec drain () = let assignment = Kafka_raw.assignment t.handle |> List.sort compare in if Some assignment <> !prev_assignment then begin if Option.is_some !prev_assignment then Hashtbl.reset t.last_processed; prev_assignment := Some assignment end; if not !notified && assignment <> [] then begin notified := true; on_ready () end; match Kafka_raw.consumer_queue_poll t.handle 0 with | Kafka_raw.Timeout -> () | Kafka_raw.Msg tup -> if not !notified then begin notified := true; on_ready () end; Eio.Stream.add t.stream (tuple_to_message tup); drain () | Kafka_raw.Poll_error code -> (* Persistent poll errors can be immediately ready; yield to avoid turning an auth/config failure into a tight scheduler loop. *) on_poll_error code; Eio.Fiber.yield (); drain () in let rec loop () = if Atomic.get t.closed then () else match Eio.Flow.single_read wake_source wake_buf with | exception (Eio.Cancel.Cancelled _) -> () | exception End_of_file -> () | _n -> drain (); loop () in Fun.protect ~finally:(fun () -> Eio.Promise.resolve t.poll_exit_r ()) (fun () -> try drain (); loop (); `Stop_daemon with Eio.Cancel.Cancelled _ -> `Stop_daemon)) let close t = if Atomic.compare_and_set t.closed false true then (* Drain before awaiting poll_exited: the poll fiber may be blocked adding to a full stream and needs room to reach its closed check. *) Eio.Cancel.protect (fun () -> Kafka_raw.consumer_queue_events_disable t.handle; (* Wake the daemon once librdkafka no longer owns this fd. *) let wfd = Atomic.get t.wake_fd in if wfd >= 0 then begin let buf = Bytes.make 1 '\x01' in (try ignore (Unix.write (fd_of_int wfd) buf 0 1) with Unix.Unix_error (Unix.EPIPE, _, _) -> () | Unix.Unix_error _ -> ()) end; let rec drain_until_exited () = while not (Eio.Stream.is_empty t.stream) do ignore (Eio.Stream.take_nonblocking t.stream) done; if Eio.Promise.peek t.poll_exited = None then begin Eio.Fiber.yield (); drain_until_exited () end in drain_until_exited (); Kafka_raw.consumer_close t.handle; Kafka_raw.destroy t.handle; Eio.Flow.close t.wake_source; Eio.Flow.close t.wake_sink) let default_on_poll_error code = Printf.eprintf "kafka-eio: consumer poll error: %s\n%!" (Kafka_error.to_string (Kafka_error.of_int code)) let create ?(on_ready = ignore) ?(on_poll_error = default_on_poll_error) (cfg : config) ~sw = match conf_of_config cfg with | Error msg -> Result.error (Kafka_error.Config_error msg) | Ok conf -> match Kafka_raw.kafka_new Kafka_raw.Consumer conf (-1) with | Error msg -> Result.error (Kafka_error.Config_error msg) | Ok rk_handle -> (match Kafka_raw.subscribe rk_handle cfg.topics with | Error msg -> Kafka_raw.destroy rk_handle; Result.error (Kafka_error.Config_error msg) | Ok () -> let (poll_exited, poll_exit_r) = Eio.Promise.create () in let (wake_source, wake_sink) = Eio_unix.pipe sw in let t = { handle = rk_handle; config = cfg; stream = Eio.Stream.create 256; closed = Atomic.make false; wake_source; wake_sink; wake_fd = Atomic.make (-1); poll_exited; poll_exit_r; last_processed = Hashtbl.create 4; } in poll_fiber t sw ~on_ready ~on_poll_error; Eio.Switch.on_release sw (fun () -> close t); Result.ok t) let is_closed t = Atomic.get t.closed let handle t = Kafka_consumer_handle.of_raw t.handle (* Every explicit commit path (commit, consume's ack, consume_partitioned's ack) goes through here so commit_all can later commit exactly what was explicitly committed, per partition — see last_processed's comment. *) let commit_tracked t ~topic ~partition ~offset = match Kafka_raw.commit_message t.handle ~topic ~partition ~offset ~async:false with | Ok () -> Hashtbl.replace t.last_processed (topic, partition) offset; Result.ok () | Error i -> err i let stream t = t.stream (* Same use-after-close guard as every other public op: closed is checked once before blocking. A concurrent close while a fetch is already blocked in Eio.Stream.take is not cancelled by it — the same limitation direct stream/Eio.Stream.take use already has, not a new one. *) let fetch t = if is_closed t then Result.error Kafka_error.Destroy else Result.ok (Eio.Stream.take t.stream) let consume t ?(on_warning = default_on_warning) ~handler () = let rec loop () = let msg = Eio.Stream.take t.stream in let acked = ref false in let ack () = acked := true; if is_closed t then Result.error Kafka_error.Destroy else commit_tracked t ~topic:msg.topic ~partition:msg.partition ~offset:msg.offset in let result = handler msg ~ack in (match result with | (Continue | Stop) when not !acked -> on_warning (Printf.sprintf "handler returned without calling ack() — offset not committed \ (topic=%s partition=%ld offset=%Ld)" msg.topic msg.partition msg.offset) | _ -> ()); match result with | Continue -> loop () | Stop -> Result.ok () | Error e -> Result.error e in loop () (* poll_fiber is always running and is the only fiber allowed to call Kafka_raw.consumer_poll — a direct consumer_poll call here would compete with it for the same underlying consumer, delivering messages nondeterministically to whichever call happened to run first. Reading the stream non-blocking keeps exactly one poll loop per consumer while still giving callers who don't want [stream]'s blocking [take] a non-blocking check. *) let poll t = if is_closed t then Result.error Kafka_error.Destroy else Result.ok (Eio.Stream.take_nonblocking t.stream) let commit t msg = if is_closed t then Result.error Kafka_error.Destroy else commit_tracked t ~topic:msg.topic ~partition:msg.partition ~offset:msg.offset let commit_all t = if is_closed t then Result.error Kafka_error.Destroy else if t.config.auto_commit then (* auto_commit = true: no explicit-commit tracking to fall back on — committing each partition's current fetch position is exactly what periodic auto-commit already does, and its documented risk (offset can advance before processing completes) already covers this combination; see commit_all's .mli comment. *) (match Kafka_raw.commit_all t.handle false with | Ok () -> Result.ok () | Error i -> err i) else (* rd_kafka_commit(rk, NULL, ...) — what Kafka_raw.commit_all does — uses each partition's current fetch position, not what was actually processed (the same bug class as #3/#17, fixed earlier for transactions). Commit exactly what commit_tracked recorded instead. *) let offsets = Hashtbl.fold (fun (topic, partition) offset acc -> (topic, partition, offset) :: acc) t.last_processed [] in (* Regression note: rd_kafka_commit with a non-NULL but empty list returns RD_KAFKA_RESP_ERR__NO_OFFSET, not success — so calling commit_all before anything was ever explicitly committed (e.g. as a periodic/shutdown flush hook, its natural use) must be special-cased here rather than left to librdkafka, or it errors on exactly the "nothing to do" case the doc promises is a no-op. *) if offsets = [] then Result.ok () else match Kafka_raw.commit_offsets t.handle offsets false with | Ok () -> Result.ok () | Error i -> err i (* ── Per-partition fiber consumer with retry + pause/resume ──────────────── *) type retry_policy = { base_delay_s : float; max_delay_s : float; max_attempts : int; } let default_retry = { base_delay_s = 1.0; max_delay_s = 600.0; max_attempts = -1; } let default_queue_capacity = 16 (* [consume_partitioned t ~sw ~clock ?retry ?on_retry ?on_warning ?queue_capacity ~handler] routes each message to a per-partition fiber so retry backoff on one partition never blocks another. During retry sleep the partition is paused at the librdkafka level so no new messages accumulate in the partition stream. An inner Eio.Switch.run — not the passed ~sw — owns the partition fibers, so they are provably joined before this function returns even if ~sw is cancelled mid-call; see the .mli for why this doesn't weaken cancellation (the function still runs synchronously within the calling fiber, which is what actually matters for ~sw's cancellation to take effect here). Each partition stream is bounded (queue_capacity) rather than unbounded, to cap per-partition buffering. routing_loop dispatches into a partition's stream with a direct, synchronous Eio.Stream.add — deliberately, not via a forked fiber. Forking one fiber per message to avoid routing_loop blocking on a full partition queue was tried and reverted: it replaces a bounded stall with unbounded growth, since every message destined for a backed-up partition accumulates as its own blocked fiber (each retaining the full message) rather than being capped by queue_capacity. A backed-up partition can therefore transiently stall routing to other partitions — bounded by queue_capacity and further limited by pause_partition already halting new deliveries to that partition during retry sleep, which is the common cause of sustained backlog. That's the accepted tradeoff for a hard memory bound. *) let consume_partitioned t ~sw:_ ~clock ?(retry = default_retry) ?(on_retry = fun ~partition:_ ~attempt:_ ~delay_s:_ -> ()) ?(on_warning = default_on_warning) ?(queue_capacity = default_queue_capacity) ~handler () = let stop = Atomic.make false in let stop_p, stop_r = Eio.Promise.create () in let first_err = ref None in let streams : (int32, (message * (unit -> (unit, Kafka_error.t) result)) option Eio.Stream.t) Hashtbl.t = Hashtbl.create 4 in let signal_stop () = if Atomic.compare_and_set stop false true then Eio.Promise.resolve stop_r () in Eio.Switch.run (fun sw -> (* Regression note: calling Kafka_consumer.close directly (rather than cancelling ~sw, the documented way to stop this function) never resolved stop_p, so routing_loop's Fiber.first (racing Stream.take against stop_p) blocked forever once poll_fiber stopped feeding t.stream — hanging this call permanently. This watchdog polls is_closed t and signals stop itself, bounding that hang to one poll interval. *) Eio.Fiber.fork_daemon ~sw (fun () -> let rec watch () = if Atomic.get stop then `Stop_daemon else if is_closed t then (signal_stop (); `Stop_daemon) else (Eio.Time.sleep clock 0.1; watch ()) in watch ()); let get_or_create_stream partition = match Hashtbl.find_opt streams partition with | Some s -> s | None -> let stream = Eio.Stream.create queue_capacity in Hashtbl.add streams partition stream; Eio.Fiber.fork ~sw (fun () -> let rec loop () = match Eio.Stream.take stream with | None -> () | Some (msg, ack) -> if Atomic.get stop || is_closed t then loop () else begin let acked = ref false in let tracked_ack () = acked := true; ack () in let rec attempt n = match handler msg ~ack:tracked_ack with | Continue -> if not !acked then on_warning (Printf.sprintf "handler returned Continue without ack() \ (topic=%s partition=%ld offset=%Ld)" msg.topic msg.partition msg.offset); loop () | Stop -> if not !acked then on_warning (Printf.sprintf "handler returned Stop without ack() \ (topic=%s partition=%ld offset=%Ld)" msg.topic msg.partition msg.offset); signal_stop (); (* Regression note: returning here instead of draining left this fiber's queue abandoned-but-full whenever routing_loop or the final None-sentinel add (below) still had something queued for it — both are plain blocking Eio.Stream.add calls, so either could hang forever with nobody left to ever take from this queue again. loop () re-enters the stop-drain branch above, discarding (without acking, so they're replayed on restart) whatever is still queued until the None sentinel arrives, which is always sent after routing_loop returns — guaranteeing this queue is never left permanently full. *) loop () | Error e -> let exhausted = retry.max_attempts >= 0 && n >= retry.max_attempts in if exhausted then begin on_warning (Printf.sprintf "exhausted %d attempt(s) for topic=%s partition=%ld offset=%Ld" (n + 1) msg.topic msg.partition msg.offset); first_err := Some e; signal_stop (); loop () (* see the Stop case above — must drain, not exit *) end else begin let delay = Float.min (retry.base_delay_s *. (2. ** Float.of_int n)) retry.max_delay_s in on_warning (Printf.sprintf "attempt %d failed, retrying in %.0fs (topic=%s partition=%ld offset=%Ld)" (n + 1) delay msg.topic msg.partition msg.offset); on_retry ~partition:msg.partition ~attempt:n ~delay_s:delay; if not (is_closed t) then Kafka_raw.pause_partition t.handle msg.topic msg.partition; let interrupted = Eio.Fiber.first (fun () -> Eio.Time.sleep clock delay; false) (fun () -> Eio.Promise.await stop_p; true) in if not (is_closed t) then Kafka_raw.resume_partition t.handle msg.topic msg.partition; if not interrupted then attempt (n + 1) else loop () (* stop_p fired mid-sleep — see the Stop case above *) end in attempt 0 end in loop () ); stream in let rec routing_loop () = if Atomic.get stop || is_closed t then () else begin let msg_opt = match Eio.Stream.take_nonblocking t.stream with | Some _ as m -> m | None -> Eio.Fiber.first (fun () -> Some (Eio.Stream.take t.stream)) (fun () -> Eio.Promise.await stop_p; None) in match msg_opt with | None -> () | Some msg -> let ack () = if is_closed t then Result.error Kafka_error.Destroy else commit_tracked t ~topic:msg.topic ~partition:msg.partition ~offset:msg.offset in Eio.Stream.add (get_or_create_stream msg.partition) (Some (msg, ack)); routing_loop () end in routing_loop (); Hashtbl.iter (fun _ s -> Eio.Stream.add s None) streams ); match !first_err with | Some e -> Result.error e | None -> Result.ok ()
sectionYPositions = computeSectionYPositions($el), 10)"
x-init="setTimeout(() => sectionYPositions = computeSectionYPositions($el), 10)"
>