chalk-remote-call-python 1.8.7__tar.gz → 1.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {chalk_remote_call_python-1.8.7/chalk_remote_call_python.egg-info → chalk_remote_call_python-1.9.1}/PKG-INFO +2 -1
  2. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/README.md +1 -0
  3. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/Cargo.lock +7 -7
  4. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-server/Cargo.toml +3 -3
  5. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-server/src/self_consumer.rs +441 -61
  6. chalk_remote_call_python-1.9.1/chalk_remote_call/_version.py +1 -0
  7. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/tracing.py +34 -7
  8. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1/chalk_remote_call_python.egg-info}/PKG-INFO +2 -1
  9. chalk_remote_call_python-1.8.7/chalk_remote_call/_version.py +0 -1
  10. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/MANIFEST.in +0 -0
  11. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/Cargo.toml +0 -0
  12. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-proto/Cargo.toml +0 -0
  13. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-proto/src/gen/chalk.auth.v1.rs +0 -0
  14. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-proto/src/gen/chalk.common.v1.rs +0 -0
  15. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-proto/src/gen/chalk.runtime.v1.rs +0 -0
  16. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-proto/src/gen/chalk.runtime.v1.tonic.rs +0 -0
  17. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-proto/src/gen/chalk.utils.v1.rs +0 -0
  18. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-proto/src/gen/descriptor.bin +0 -0
  19. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-proto/src/lib.rs +0 -0
  20. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-server/src/async_service.rs +0 -0
  21. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-server/src/coalesce.rs +0 -0
  22. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-server/src/lib.rs +0 -0
  23. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-server/src/metrics.rs +0 -0
  24. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-server/src/python_bridge.rs +0 -0
  25. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-server/src/server.rs +0 -0
  26. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/chalk-remote-call-server/src/service.rs +0 -0
  27. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk-remote-call-rs/rust-toolchain.toml +0 -0
  28. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/__init__.py +0 -0
  29. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/__main__.py +0 -0
  30. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/__init__.py +0 -0
  31. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/__init__.py +0 -0
  32. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/auth/__init__.py +0 -0
  33. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/auth/v1/__init__.py +0 -0
  34. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/auth/v1/permissions_pb2.py +0 -0
  35. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/auth/v1/permissions_pb2_grpc.py +0 -0
  36. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/common/__init__.py +0 -0
  37. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/common/v1/__init__.py +0 -0
  38. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/common/v1/chalk_error_pb2.py +0 -0
  39. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/common/v1/chalk_error_pb2_grpc.py +0 -0
  40. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/runtime/__init__.py +0 -0
  41. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/runtime/v1/__init__.py +0 -0
  42. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/runtime/v1/remote_python_call_pb2.py +0 -0
  43. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/runtime/v1/remote_python_call_pb2_grpc.py +0 -0
  44. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/utils/__init__.py +0 -0
  45. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/utils/v1/__init__.py +0 -0
  46. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/utils/v1/encoding_pb2.py +0 -0
  47. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/utils/v1/encoding_pb2_grpc.py +0 -0
  48. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/utils/v1/field_change_pb2.py +0 -0
  49. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/utils/v1/field_change_pb2_grpc.py +0 -0
  50. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/utils/v1/sensitive_pb2.py +0 -0
  51. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_gen/chalk/utils/v1/sensitive_pb2_grpc.py +0 -0
  52. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/_native.pyi +0 -0
  53. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/arrow_utils.py +0 -0
  54. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/cli.py +0 -0
  55. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/handler_loader.py +0 -0
  56. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/input_transform.py +0 -0
  57. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/server.py +0 -0
  58. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call/servicer.py +0 -0
  59. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call_python.egg-info/SOURCES.txt +0 -0
  60. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call_python.egg-info/dependency_links.txt +0 -0
  61. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call_python.egg-info/entry_points.txt +0 -0
  62. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call_python.egg-info/requires.txt +0 -0
  63. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/chalk_remote_call_python.egg-info/top_level.txt +0 -0
  64. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/pyproject.toml +0 -0
  65. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/setup.cfg +0 -0
  66. {chalk_remote_call_python-1.8.7 → chalk_remote_call_python-1.9.1}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chalk-remote-call-python
3
- Version: 1.8.7
3
+ Version: 1.9.1
4
4
  Summary: Chalk remote call Python runtime interface client
5
5
  Author: Chalk AI, Inc.
6
6
  Project-URL: Homepage, https://chalk.ai
@@ -113,6 +113,7 @@ python -m chalk_remote_call --handler my_handler.handler
113
113
  | Variable | Description |
114
114
  |----------|-------------|
115
115
  | `CHALK_INPUT_ARGS` | Comma-separated list of column names (e.g. `x,y,z`). Renames incoming RecordBatch columns by index. If unset, the original column names are used. Reserved columns (below) are held out of this mapping and keep their own name. |
116
+ | `CHALK_FNQ_QUEUE_PROTOCOL` | Self-consumer work transport: `list_v1` (default/backward compatible) or `stream_v1`. Deployment tooling sets this from immutable scaling-group revision metadata. |
116
117
 
117
118
  #### Reserved columns
118
119
 
@@ -75,6 +75,7 @@ python -m chalk_remote_call --handler my_handler.handler
75
75
  | Variable | Description |
76
76
  |----------|-------------|
77
77
  | `CHALK_INPUT_ARGS` | Comma-separated list of column names (e.g. `x,y,z`). Renames incoming RecordBatch columns by index. If unset, the original column names are used. Reserved columns (below) are held out of this mapping and keep their own name. |
78
+ | `CHALK_FNQ_QUEUE_PROTOCOL` | Self-consumer work transport: `list_v1` (default/backward compatible) or `stream_v1`. Deployment tooling sets this from immutable scaling-group revision metadata. |
78
79
 
79
80
  #### Reserved columns
80
81
 
@@ -484,7 +484,7 @@ dependencies = [
484
484
  [[package]]
485
485
  name = "chalk-core"
486
486
  version = "0.0.0"
487
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=ae8e792f746f77253f682ee0c56b246cfdd88f51#ae8e792f746f77253f682ee0c56b246cfdd88f51"
487
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=783e9ce6b2c#783e9ce6b2c17563ab3302e5d11f8a664ed00232"
488
488
  dependencies = [
489
489
  "anyhow",
490
490
  "envconfig",
@@ -499,7 +499,7 @@ dependencies = [
499
499
  [[package]]
500
500
  name = "chalk-protos"
501
501
  version = "0.0.0"
502
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=ae8e792f746f77253f682ee0c56b246cfdd88f51#ae8e792f746f77253f682ee0c56b246cfdd88f51"
502
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=783e9ce6b2c#783e9ce6b2c17563ab3302e5d11f8a664ed00232"
503
503
  dependencies = [
504
504
  "prost 0.11.9",
505
505
  "prost-build",
@@ -511,7 +511,7 @@ dependencies = [
511
511
  [[package]]
512
512
  name = "chalk-queue"
513
513
  version = "0.0.0"
514
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=ae8e792f746f77253f682ee0c56b246cfdd88f51#ae8e792f746f77253f682ee0c56b246cfdd88f51"
514
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=9c61f6a938d8f885f138217ac64149e29d7a691e#9c61f6a938d8f885f138217ac64149e29d7a691e"
515
515
  dependencies = [
516
516
  "base64 0.22.1",
517
517
  "redis",
@@ -560,7 +560,7 @@ dependencies = [
560
560
  [[package]]
561
561
  name = "chalk_metric_definitions"
562
562
  version = "0.0.0"
563
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=ae8e792f746f77253f682ee0c56b246cfdd88f51#ae8e792f746f77253f682ee0c56b246cfdd88f51"
563
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=783e9ce6b2c#783e9ce6b2c17563ab3302e5d11f8a664ed00232"
564
564
  dependencies = [
565
565
  "chalk-protos",
566
566
  "metrics-generator",
@@ -570,7 +570,7 @@ dependencies = [
570
570
  [[package]]
571
571
  name = "chalk_metrics"
572
572
  version = "0.0.0"
573
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=ae8e792f746f77253f682ee0c56b246cfdd88f51#ae8e792f746f77253f682ee0c56b246cfdd88f51"
573
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=783e9ce6b2c#783e9ce6b2c17563ab3302e5d11f8a664ed00232"
574
574
  dependencies = [
575
575
  "async-trait",
576
576
  "chalk-core",
@@ -604,7 +604,7 @@ dependencies = [
604
604
  [[package]]
605
605
  name = "chalk_statsd"
606
606
  version = "0.0.0"
607
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=ae8e792f746f77253f682ee0c56b246cfdd88f51#ae8e792f746f77253f682ee0c56b246cfdd88f51"
607
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=783e9ce6b2c#783e9ce6b2c17563ab3302e5d11f8a664ed00232"
608
608
  dependencies = [
609
609
  "chalk-core",
610
610
  "chrono",
@@ -1867,7 +1867,7 @@ dependencies = [
1867
1867
  [[package]]
1868
1868
  name = "metrics-generator"
1869
1869
  version = "0.1.0"
1870
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=ae8e792f746f77253f682ee0c56b246cfdd88f51#ae8e792f746f77253f682ee0c56b246cfdd88f51"
1870
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=783e9ce6b2c#783e9ce6b2c17563ab3302e5d11f8a664ed00232"
1871
1871
  dependencies = [
1872
1872
  "codegen",
1873
1873
  "serde",
@@ -36,6 +36,6 @@ futures-util = "0.3"
36
36
  # `[patch.crates-io]` here it builds against vanilla crates.io redis (it only
37
37
  # uses standard APIs). Same rev across all three so cargo reuses one checkout.
38
38
  [target.'cfg(target_os = "linux")'.dependencies]
39
- chalk-queue = { git = "https://github.com/chalk-ai/chalk-private.git", rev = "ae8e792f746f77253f682ee0c56b246cfdd88f51" }
40
- chalk_metrics = { git = "https://github.com/chalk-ai/chalk-private.git", rev = "ae8e792f746f77253f682ee0c56b246cfdd88f51" }
41
- chalk_statsd = { git = "https://github.com/chalk-ai/chalk-private.git", rev = "ae8e792f746f77253f682ee0c56b246cfdd88f51" }
39
+ chalk-queue = { git = "https://github.com/chalk-ai/chalk-private.git", rev = "9c61f6a938d8f885f138217ac64149e29d7a691e" }
40
+ chalk_metrics = { git = "https://github.com/chalk-ai/chalk-private.git", rev = "783e9ce6b2c" }
41
+ chalk_statsd = { git = "https://github.com/chalk-ai/chalk-private.git", rev = "783e9ce6b2c" }
@@ -3,7 +3,10 @@
3
3
  //! This runs *alongside* the gRPC server, which is untouched: the synchronous
4
4
  //! / direct `CallFunction` path keeps serving inbound calls.
5
5
  //!
6
- //! Concurrency is capped by a Redis-backed distributed semaphore (per function,
6
+ //! Work is read from the revision's advertised Redis List or Stream transport.
7
+ //! Stream deliveries are acknowledged only after result writes complete and
8
+ //! reclaimed after a crashed owner stops heartbeating. Concurrency is capped
9
+ //! by a Redis-backed distributed semaphore (per function,
7
10
  //! across every pod in the scaling group). The dispatch loop dispatches as fast
8
11
  //! as it can acquire a slot and pop work; in-flight worker tasks are therefore
9
12
  //! bounded by `max_concurrent`, not by traffic. A single per-pod renewer task
@@ -30,14 +33,17 @@
30
33
 
31
34
  use std::collections::HashMap;
32
35
  use std::panic::AssertUnwindSafe;
36
+ use std::str::FromStr;
33
37
  use std::sync::{Arc, Mutex};
34
38
  use std::time::{Duration, Instant};
35
39
 
36
40
  use chalk_queue::semaphore::renew_many;
37
41
  use chalk_queue::stream_writer;
38
42
  use chalk_queue::types::QueuedCall;
39
- use chalk_queue::{FunctionQueue, SemaphoreLease};
40
- use dashmap::DashSet;
43
+ use chalk_queue::{
44
+ BoundFunctionQueue, DequeuedCall, FunctionQueue, QueueProtocol, QueueReceipt, SemaphoreLease,
45
+ };
46
+ use dashmap::DashMap;
41
47
  use futures_util::FutureExt;
42
48
  use redis::aio::ConnectionManager;
43
49
  use redis::Client;
@@ -59,6 +65,8 @@ const ENV_SEM_TTL_SECS: &str = "CHALK_FNQ_SEM_TTL_SECS";
59
65
  const ENV_POLL_BACKOFF_INITIAL_MS: &str = "CHALK_FNQ_POLL_BACKOFF_INITIAL_MS";
60
66
  const ENV_POLL_BACKOFF_MAX_SECS: &str = "CHALK_FNQ_POLL_BACKOFF_MAX_SECS";
61
67
  const ENV_FLUSH_TIMEOUT_MS: &str = "CHALK_FNQ_FLUSH_TIMEOUT_MS";
68
+ const ENV_QUEUE_PROTOCOL: &str = "CHALK_FNQ_QUEUE_PROTOCOL";
69
+ const ENV_STREAM_READ_COUNT: &str = "CHALK_FNQ_STREAM_READ_COUNT";
62
70
 
63
71
  /// Lease lifetime (default). Intentionally short relative to how long a call may run: the
64
72
  /// per-pod renewer keeps a live call's lease alive (see module docs), so the
@@ -81,6 +89,14 @@ const DEFAULT_POLL_BACKOFF_MAX_SECS: u64 = 2;
81
89
  /// chunk append races with the wait setup.
82
90
  /// Configurable via `CHALK_FNQ_FLUSH_TIMEOUT_MS`.
83
91
  const DEFAULT_FLUSH_TIMEOUT_MS: u64 = 100;
92
+ /// A bounded prefetch window for Redis Streams. This is deliberately much
93
+ /// smaller than a logical handler batch: several reads can feed the shared
94
+ /// coalescer, while one read remains cheap to reserve, decode and recover.
95
+ const DEFAULT_STREAM_READ_COUNT: usize = 64;
96
+ /// Recovery does not belong on the normal dequeue path. A dead consumer is
97
+ /// still recovered promptly, while healthy traffic avoids an XAUTOCLAIM RPC
98
+ /// for every read window.
99
+ const STREAM_RECLAIM_INTERVAL: Duration = Duration::from_secs(10);
84
100
 
85
101
  /// `peer` recorded in the handler context for queue-consumed calls; there is no
86
102
  /// inbound network peer on this path.
@@ -101,6 +117,8 @@ pub struct SelfConsumerConfig {
101
117
  pub poll_backoff_initial: Duration,
102
118
  pub poll_backoff_max: Duration,
103
119
  pub flush_timeout: Duration,
120
+ pub queue_protocol: QueueProtocol,
121
+ pub stream_read_count: usize,
104
122
  }
105
123
 
106
124
  impl SelfConsumerConfig {
@@ -162,6 +180,22 @@ impl SelfConsumerConfig {
162
180
  .filter(|&n| n > 0)
163
181
  .unwrap_or(DEFAULT_FLUSH_TIMEOUT_MS);
164
182
  let flush_timeout = Duration::from_millis(flush_timeout_ms);
183
+ let queue_protocol = std::env::var(ENV_QUEUE_PROTOCOL)
184
+ .ok()
185
+ .filter(|value| !value.is_empty())
186
+ .map(|value| {
187
+ QueueProtocol::from_str(&value).unwrap_or_else(|error| {
188
+ warn!(%error, "invalid queue protocol; using legacy List transport");
189
+ QueueProtocol::ListV1
190
+ })
191
+ })
192
+ .unwrap_or_default();
193
+ let stream_read_count = std::env::var(ENV_STREAM_READ_COUNT)
194
+ .ok()
195
+ .and_then(|s| s.parse::<usize>().ok())
196
+ .filter(|&n| n > 0)
197
+ .unwrap_or(DEFAULT_STREAM_READ_COUNT)
198
+ .min(max_concurrent as usize);
165
199
 
166
200
  Some(Self {
167
201
  redis_url,
@@ -172,6 +206,8 @@ impl SelfConsumerConfig {
172
206
  poll_backoff_initial,
173
207
  poll_backoff_max,
174
208
  flush_timeout,
209
+ queue_protocol,
210
+ stream_read_count,
175
211
  })
176
212
  }
177
213
  }
@@ -179,7 +215,7 @@ impl SelfConsumerConfig {
179
215
  /// Set of lease tokens this pod currently holds. The dispatch loop inserts a
180
216
  /// token when it admits a call; the worker removes it on completion. The
181
217
  /// renewer heartbeats whatever is in the set on each tick.
182
- type LiveTokens = Arc<DashSet<String>>;
218
+ type LiveCalls = Arc<DashMap<String, QueueReceipt>>;
183
219
 
184
220
  /// Connect to Redis and run the consume loop until the process exits. Returns
185
221
  /// `Err` only on a fatal setup failure (bad URL / unreachable Redis at startup);
@@ -194,25 +230,64 @@ pub async fn run(
194
230
  let client = Client::open(config.redis_url.clone())?;
195
231
  let conn = ConnectionManager::new(client).await?;
196
232
  let queue = FunctionQueue::new(conn.clone(), config.tenant.clone());
233
+ let input_queue = queue.bind(&config.function_name, config.queue_protocol);
234
+ let legacy_queue = (config.queue_protocol == QueueProtocol::StreamV1)
235
+ .then(|| queue.bind(&config.function_name, QueueProtocol::ListV1));
197
236
  let sem = queue.semaphore(&config.function_name, config.max_concurrent, config.sem_ttl);
198
237
  let sem_key = queue.sem_key_for(&config.function_name);
199
- let live_tokens: LiveTokens = Arc::new(DashSet::new());
238
+ let live_calls: LiveCalls = Arc::new(DashMap::new());
239
+ let consumer_name = format!(
240
+ "{}:{}",
241
+ std::env::var("HOSTNAME").unwrap_or_else(|_| "fnq-shim".to_string()),
242
+ Uuid::new_v4()
243
+ );
200
244
 
201
- spawn_renewer(conn.clone(), sem_key, live_tokens.clone(), config.sem_ttl);
245
+ // XGROUP CREATE is a startup operation. Keeping it out of every XREAD and
246
+ // XAUTOCLAIM avoids two avoidable Redis round trips per dispatch window.
247
+ input_queue.initialize_consumer_group().await?;
248
+
249
+ spawn_renewer(
250
+ input_queue.clone(),
251
+ conn.clone(),
252
+ sem_key,
253
+ live_calls.clone(),
254
+ config.sem_ttl,
255
+ );
202
256
 
203
257
  info!(
204
258
  function = %config.function_name,
205
259
  tenant = %config.tenant,
206
260
  max_concurrent = config.max_concurrent,
261
+ queue_protocol = config.queue_protocol.as_str(),
262
+ stream_read_count = config.stream_read_count,
263
+ consumer = %consumer_name,
207
264
  "fnq self-consumer started"
208
265
  );
209
266
 
267
+ if config.queue_protocol == QueueProtocol::StreamV1 {
268
+ return run_stream_dispatch_loop(
269
+ &config,
270
+ queue,
271
+ input_queue,
272
+ legacy_queue,
273
+ sem,
274
+ conn,
275
+ consumer_name,
276
+ live_calls,
277
+ handler,
278
+ metrics_pipeline,
279
+ coalescing_queue,
280
+ )
281
+ .await;
282
+ }
283
+
210
284
  let mut backoff = config.poll_backoff_initial;
211
285
  loop {
212
286
  // Idle-gate: if there is no work, don't take (then immediately release)
213
287
  // a semaphore slot — that churn would race genuine acquirers on other
214
- // pods. LLEN is as cheap as the RPOP we'd otherwise do.
215
- match queue.current_depth(&config.function_name).await {
288
+ // pods. During a Stream rollout, also observe the legacy List so a new
289
+ // shim can drain work queued before its revision metadata changed.
290
+ match pending_depth(&input_queue, legacy_queue.as_ref()).await {
216
291
  Ok(0) => {
217
292
  tokio::time::sleep(backoff).await;
218
293
  backoff = (backoff * 2).min(config.poll_backoff_max);
@@ -243,8 +318,15 @@ pub async fn run(
243
318
  }
244
319
  };
245
320
 
246
- let work = match queue.pop(&config.function_name).await {
247
- Ok(Some(work)) => work,
321
+ let delivery = match dequeue_next(
322
+ &input_queue,
323
+ legacy_queue.as_ref(),
324
+ &consumer_name,
325
+ config.sem_ttl,
326
+ )
327
+ .await
328
+ {
329
+ Ok(Some(delivery)) => delivery,
248
330
  // Raced another consumer (or our own idle-gate) to an empty queue:
249
331
  // give the slot back and retry.
250
332
  Ok(None) => {
@@ -253,7 +335,7 @@ pub async fn run(
253
335
  continue;
254
336
  }
255
337
  Err(e) => {
256
- warn!(error = %e, "pop failed; releasing slot");
338
+ warn!(error = %e, "dequeue failed; releasing slot");
257
339
  let _ = lease.release().await;
258
340
  backoff = (backoff * 2).min(config.poll_backoff_max);
259
341
  continue;
@@ -264,7 +346,7 @@ pub async fn run(
264
346
 
265
347
  // Register the token so the pod-level renewer heartbeats this lease
266
348
  // while the call runs.
267
- live_tokens.insert(lease.token().to_string());
349
+ live_calls.insert(lease.token().to_string(), delivery.receipt.clone());
268
350
 
269
351
  match &coalescing_queue {
270
352
  // Batching on: route through the shared coalescing queue so this
@@ -273,11 +355,12 @@ pub async fn run(
273
355
  Some(cq) => {
274
356
  tokio::spawn(run_one_coalesced_call(
275
357
  queue.clone(),
358
+ input_queue.clone(),
276
359
  cq.clone(),
277
360
  conn.clone(),
278
- work,
361
+ delivery,
279
362
  lease,
280
- live_tokens.clone(),
363
+ live_calls.clone(),
281
364
  metrics_pipeline.clone(),
282
365
  ));
283
366
  }
@@ -286,10 +369,11 @@ pub async fn run(
286
369
  tokio::spawn(run_one_call(
287
370
  handler.clone(),
288
371
  queue.clone(),
372
+ input_queue.clone(),
289
373
  conn.clone(),
290
- work,
374
+ delivery,
291
375
  lease,
292
- live_tokens.clone(),
376
+ live_calls.clone(),
293
377
  config.flush_timeout,
294
378
  metrics_pipeline.clone(),
295
379
  ));
@@ -300,6 +384,202 @@ pub async fn run(
300
384
  }
301
385
  }
302
386
 
387
+ #[allow(clippy::too_many_arguments)]
388
+ async fn run_stream_dispatch_loop(
389
+ config: &SelfConsumerConfig,
390
+ queue: FunctionQueue,
391
+ input_queue: BoundFunctionQueue,
392
+ legacy_queue: Option<BoundFunctionQueue>,
393
+ sem: chalk_queue::DistributedSemaphore,
394
+ conn: ConnectionManager,
395
+ consumer_name: String,
396
+ live_calls: LiveCalls,
397
+ handler: Arc<PythonHandler>,
398
+ metrics_pipeline: Option<Arc<PublishingMetricsPipeline>>,
399
+ coalescing_queue: Option<Arc<CoalescingQueue>>,
400
+ ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
401
+ let mut backoff = config.poll_backoff_initial;
402
+ // Reclaim once immediately to recover work from a prior crashed pod, then
403
+ // periodically. Healthy messages are kept alive by `spawn_renewer`.
404
+ let mut last_reclaim = Instant::now() - STREAM_RECLAIM_INTERVAL;
405
+
406
+ loop {
407
+ // Capacity must be reserved before XREADGROUP. Reading first would put
408
+ // a potentially huge number of messages into this consumer's PEL while
409
+ // they wait for semaphore slots.
410
+ let mut leases = match sem.try_acquire_many(config.stream_read_count).await {
411
+ Ok(leases) if !leases.is_empty() => leases,
412
+ Ok(_) => {
413
+ tokio::time::sleep(backoff).await;
414
+ backoff = (backoff * 2).min(config.poll_backoff_max);
415
+ continue;
416
+ }
417
+ Err(error) => {
418
+ warn!(%error, "batch semaphore acquire failed; backing off");
419
+ tokio::time::sleep(backoff).await;
420
+ backoff = (backoff * 2).min(config.poll_backoff_max);
421
+ continue;
422
+ }
423
+ };
424
+
425
+ let mut deliveries = Vec::with_capacity(leases.len());
426
+ if last_reclaim.elapsed() >= STREAM_RECLAIM_INTERVAL {
427
+ last_reclaim = Instant::now();
428
+ match input_queue
429
+ .reclaim_stale(&consumer_name, config.sem_ttl, leases.len())
430
+ .await
431
+ {
432
+ Ok(reclaimed) => deliveries.extend(reclaimed),
433
+ Err(error) => {
434
+ warn!(%error, "Stream recovery scan failed; continuing with new work")
435
+ }
436
+ }
437
+ }
438
+
439
+ // During the one-way List → Stream migration, drain old List entries
440
+ // before reading new Stream traffic. This is one bounded operation per
441
+ // read window, not an LLEN on every Stream message.
442
+ if deliveries.len() < leases.len() {
443
+ if let Some(legacy_queue) = &legacy_queue {
444
+ match legacy_queue
445
+ .dequeue_many(&consumer_name, leases.len() - deliveries.len())
446
+ .await
447
+ {
448
+ Ok(legacy) if !legacy.is_empty() => {
449
+ info!(function = %input_queue.function_name(), count = legacy.len(), "draining legacy List items during Stream migration");
450
+ deliveries.extend(legacy);
451
+ }
452
+ Ok(_) => {}
453
+ Err(error) => {
454
+ warn!(%error, "legacy List dequeue failed; continuing with Stream work")
455
+ }
456
+ }
457
+ }
458
+ }
459
+ if deliveries.len() < leases.len() {
460
+ match input_queue
461
+ .dequeue_many(&consumer_name, leases.len() - deliveries.len())
462
+ .await
463
+ {
464
+ Ok(new_deliveries) => deliveries.extend(new_deliveries),
465
+ Err(error) => warn!(%error, "batched Stream dequeue failed"),
466
+ }
467
+ }
468
+
469
+ if deliveries.is_empty() {
470
+ for lease in leases {
471
+ let _ = lease.release().await;
472
+ }
473
+ tokio::time::sleep(backoff).await;
474
+ backoff = (backoff * 2).min(config.poll_backoff_max);
475
+ continue;
476
+ }
477
+
478
+ backoff = config.poll_backoff_initial;
479
+ // `dequeue_many` can return fewer records than requested. Only retain
480
+ // one lease per record and immediately return the unneeded capacity.
481
+ let used = deliveries.len().min(leases.len());
482
+ let unused = leases.split_off(used);
483
+ for lease in unused {
484
+ let _ = lease.release().await;
485
+ }
486
+ for (delivery, lease) in deliveries.into_iter().zip(leases) {
487
+ dispatch_delivery(
488
+ queue.clone(),
489
+ input_queue.clone(),
490
+ coalescing_queue.clone(),
491
+ handler.clone(),
492
+ conn.clone(),
493
+ delivery,
494
+ lease,
495
+ live_calls.clone(),
496
+ config.flush_timeout,
497
+ metrics_pipeline.clone(),
498
+ );
499
+ }
500
+ }
501
+ }
502
+
503
+ #[allow(clippy::too_many_arguments)]
504
+ fn dispatch_delivery(
505
+ queue: FunctionQueue,
506
+ input_queue: BoundFunctionQueue,
507
+ coalescing_queue: Option<Arc<CoalescingQueue>>,
508
+ handler: Arc<PythonHandler>,
509
+ conn: ConnectionManager,
510
+ delivery: DequeuedCall,
511
+ lease: SemaphoreLease,
512
+ live_calls: LiveCalls,
513
+ flush_timeout: Duration,
514
+ metrics_pipeline: Option<Arc<PublishingMetricsPipeline>>,
515
+ ) {
516
+ live_calls.insert(lease.token().to_string(), delivery.receipt.clone());
517
+ match coalescing_queue {
518
+ Some(cq) => {
519
+ tokio::spawn(run_one_coalesced_call(
520
+ queue,
521
+ input_queue,
522
+ cq,
523
+ conn,
524
+ delivery,
525
+ lease,
526
+ live_calls,
527
+ metrics_pipeline,
528
+ ));
529
+ }
530
+ None => {
531
+ tokio::spawn(run_one_call(
532
+ handler,
533
+ queue,
534
+ input_queue,
535
+ conn,
536
+ delivery,
537
+ lease,
538
+ live_calls,
539
+ flush_timeout,
540
+ metrics_pipeline,
541
+ ));
542
+ }
543
+ }
544
+ }
545
+
546
+ async fn pending_depth(
547
+ queue: &BoundFunctionQueue,
548
+ legacy_queue: Option<&BoundFunctionQueue>,
549
+ ) -> Result<u64, redis::RedisError> {
550
+ let selected = queue.current_depth().await?;
551
+ let legacy = match legacy_queue {
552
+ Some(queue) => queue.current_depth().await?,
553
+ None => 0,
554
+ };
555
+ Ok(selected + legacy)
556
+ }
557
+
558
+ async fn dequeue_next(
559
+ queue: &BoundFunctionQueue,
560
+ legacy_queue: Option<&BoundFunctionQueue>,
561
+ consumer_name: &str,
562
+ reclaim_idle: Duration,
563
+ ) -> Result<Option<DequeuedCall>, Box<dyn std::error::Error + Send + Sync>> {
564
+ // Recovered work wins first. The legacy List is then drained before new
565
+ // Stream traffic; producers never route new work to both transports.
566
+ if let Some(reclaimed) = queue
567
+ .reclaim_stale(consumer_name, reclaim_idle, 1)
568
+ .await?
569
+ .into_iter()
570
+ .next()
571
+ {
572
+ return Ok(Some(reclaimed));
573
+ }
574
+ if let Some(legacy_queue) = legacy_queue {
575
+ if let Some(legacy) = legacy_queue.dequeue(consumer_name).await? {
576
+ info!(function = %queue.function_name(), "draining legacy List item during Stream migration");
577
+ return Ok(Some(legacy));
578
+ }
579
+ }
580
+ queue.dequeue(consumer_name).await
581
+ }
582
+
303
583
  /// Run a single queued call to completion, write its results to the call's
304
584
  /// stream, then deregister + release its lease. Always releases the lease,
305
585
  /// even if execution or stream writes fail or panic.
@@ -307,15 +587,20 @@ pub async fn run(
307
587
  async fn run_one_call(
308
588
  handler: Arc<PythonHandler>,
309
589
  queue: FunctionQueue,
590
+ input_queue: BoundFunctionQueue,
310
591
  mut conn: ConnectionManager,
311
- work: QueuedCall,
592
+ delivery: DequeuedCall,
312
593
  lease: SemaphoreLease,
313
- live_tokens: LiveTokens,
594
+ live_calls: LiveCalls,
314
595
  flush_timeout: Duration,
315
596
  metrics_pipeline: Option<Arc<PublishingMetricsPipeline>>,
316
597
  ) {
598
+ let DequeuedCall {
599
+ call: work,
600
+ receipt,
601
+ } = delivery;
317
602
  let call_id = work.call_id.clone();
318
- match AssertUnwindSafe(execute_and_write(
603
+ let completed = match AssertUnwindSafe(execute_and_write(
319
604
  &handler,
320
605
  &queue,
321
606
  &mut conn,
@@ -326,11 +611,12 @@ async fn run_one_call(
326
611
  .catch_unwind()
327
612
  .await
328
613
  {
329
- Ok(Ok(())) => {}
614
+ Ok(Ok(())) => true,
330
615
  Ok(Err(e)) => {
331
616
  // The call's result stream may be incomplete; the call's 24h stream TTL
332
617
  // and the meta status are the backstop for a stuck poller.
333
618
  error!(call_id = %call_id, error = %e, "failed to execute/write queued call");
619
+ false
334
620
  }
335
621
  Err(panic_payload) => {
336
622
  let panic_message = panic_payload
@@ -343,12 +629,41 @@ async fn run_one_call(
343
629
  panic = %panic_message,
344
630
  "panic while executing/writing queued call"
345
631
  );
632
+ false
346
633
  }
347
- }
634
+ };
348
635
 
349
- // Deregister before releasing so the renewer stops heartbeating this token
350
- // immediately; the subsequent release ZREMs it from the semaphore ZSET.
351
- live_tokens.remove(lease.token());
636
+ finish_delivery(
637
+ &input_queue,
638
+ &receipt,
639
+ completed,
640
+ &call_id,
641
+ lease,
642
+ &live_calls,
643
+ )
644
+ .await;
645
+ }
646
+
647
+ async fn finish_delivery(
648
+ queue: &BoundFunctionQueue,
649
+ receipt: &QueueReceipt,
650
+ completed: bool,
651
+ call_id: &str,
652
+ lease: SemaphoreLease,
653
+ live_calls: &LiveCalls,
654
+ ) {
655
+ // Stop renewing before ack. If completion or ack failed, the Stream item
656
+ // becomes reclaimable after the idle timeout; List behavior is unchanged.
657
+ live_calls.remove(lease.token());
658
+ if completed {
659
+ match queue.acknowledge(receipt).await {
660
+ Ok(true) => {}
661
+ Ok(false) => warn!(%call_id, "delivery ownership was lost before acknowledgement"),
662
+ Err(error) => {
663
+ warn!(%call_id, %error, "delivery acknowledgement failed; item will be reclaimed")
664
+ }
665
+ }
666
+ }
352
667
  if let Err(e) = lease.release().await {
353
668
  warn!(call_id = %call_id, error = %e, "failed to release semaphore slot (will auto-recover after ttl)");
354
669
  }
@@ -357,17 +672,23 @@ async fn run_one_call(
357
672
  /// Coalesced counterpart to [`run_one_call`]: submit the call into the shared
358
673
  /// [`CoalescingQueue`], write its single result blob to the call's stream, then
359
674
  /// deregister + release its lease (always, even on failure).
675
+ #[allow(clippy::too_many_arguments)]
360
676
  async fn run_one_coalesced_call(
361
677
  queue: FunctionQueue,
678
+ input_queue: BoundFunctionQueue,
362
679
  coalescing_queue: Arc<CoalescingQueue>,
363
680
  mut conn: ConnectionManager,
364
- work: QueuedCall,
681
+ delivery: DequeuedCall,
365
682
  lease: SemaphoreLease,
366
- live_tokens: LiveTokens,
683
+ live_calls: LiveCalls,
367
684
  metrics_pipeline: Option<Arc<PublishingMetricsPipeline>>,
368
685
  ) {
686
+ let DequeuedCall {
687
+ call: work,
688
+ receipt,
689
+ } = delivery;
369
690
  let call_id = work.call_id.clone();
370
- if let Err(e) = coalesced_execute_and_write(
691
+ let completed = match coalesced_execute_and_write(
371
692
  &coalescing_queue,
372
693
  &queue,
373
694
  &mut conn,
@@ -376,17 +697,24 @@ async fn run_one_coalesced_call(
376
697
  )
377
698
  .await
378
699
  {
379
- // The call's result stream may be incomplete; the 24h stream TTL and
380
- // the meta status are the backstop for a stuck poller.
381
- error!(call_id = %call_id, error = %e, "failed to execute/write coalesced queued call");
382
- }
700
+ Ok(()) => true,
701
+ Err(e) => {
702
+ // The call's result stream may be incomplete; the 24h stream TTL and
703
+ // the meta status are the backstop for a stuck poller.
704
+ error!(call_id = %call_id, error = %e, "failed to execute/write coalesced queued call");
705
+ false
706
+ }
707
+ };
383
708
 
384
- // Deregister before releasing so the renewer stops heartbeating this token
385
- // immediately; the subsequent release ZREMs it from the semaphore ZSET.
386
- live_tokens.remove(lease.token());
387
- if let Err(e) = lease.release().await {
388
- warn!(call_id = %call_id, error = %e, "failed to release semaphore slot (will auto-recover after ttl)");
389
- }
709
+ finish_delivery(
710
+ &input_queue,
711
+ &receipt,
712
+ completed,
713
+ &call_id,
714
+ lease,
715
+ &live_calls,
716
+ )
717
+ .await;
390
718
  }
391
719
 
392
720
  /// Submit one call into the shared coalescing queue and relay its single
@@ -666,14 +994,15 @@ async fn flush_new(
666
994
 
667
995
  /// Spawn the single per-pod renewer: every `SEM_RENEW_INTERVAL` it heartbeats
668
996
  /// all currently-held lease tokens in one `renew_many` round-trip. A reclaimed
669
- /// token means this pod stalled past `sem_ttl` without renewing (the slot was
670
- /// then handed to another consumer); the work was already popped so it won't be
671
- /// re-run from the queue, and a GIL-blocked handler can't be aborted, so we only
672
- /// surface it.
997
+ /// token means this pod stalled past `sem_ttl` without renewing. Stream
998
+ /// receipts are renewed in grouped round trips too; ownership loss is surfaced
999
+ /// because a GIL-blocked handler cannot be aborted, while the reclaimed call may
1000
+ /// already be running on another pod.
673
1001
  fn spawn_renewer(
1002
+ queue: BoundFunctionQueue,
674
1003
  mut conn: ConnectionManager,
675
1004
  sem_key: String,
676
- live_tokens: LiveTokens,
1005
+ live_calls: LiveCalls,
677
1006
  sem_ttl: Duration,
678
1007
  ) {
679
1008
  tokio::spawn(async move {
@@ -681,7 +1010,11 @@ fn spawn_renewer(
681
1010
  ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
682
1011
  loop {
683
1012
  ticker.tick().await;
684
- let tokens: Vec<String> = live_tokens.iter().map(|r| r.key().clone()).collect();
1013
+ let active: Vec<(String, QueueReceipt)> = live_calls
1014
+ .iter()
1015
+ .map(|entry| (entry.key().clone(), entry.value().clone()))
1016
+ .collect();
1017
+ let tokens: Vec<String> = active.iter().map(|(token, _)| token.clone()).collect();
685
1018
  if tokens.is_empty() {
686
1019
  continue;
687
1020
  }
@@ -695,6 +1028,16 @@ fn spawn_renewer(
695
1028
  Ok(_) => {}
696
1029
  Err(e) => warn!(error = %e, "batch lease renew failed"),
697
1030
  }
1031
+ let receipts: Vec<QueueReceipt> =
1032
+ active.into_iter().map(|(_, receipt)| receipt).collect();
1033
+ match queue.heartbeat_many(&receipts).await {
1034
+ Ok(lost) if !lost.is_empty() => warn!(
1035
+ count = lost.len(),
1036
+ "Stream deliveries reclaimed while calls were still running"
1037
+ ),
1038
+ Ok(_) => {}
1039
+ Err(error) => warn!(%error, "batch Stream delivery renewal failed"),
1040
+ }
698
1041
  }
699
1042
  });
700
1043
  }
@@ -736,6 +1079,8 @@ mod tests {
736
1079
  std::env::remove_var(ENV_POLL_BACKOFF_INITIAL_MS);
737
1080
  std::env::remove_var(ENV_POLL_BACKOFF_MAX_SECS);
738
1081
  std::env::remove_var(ENV_FLUSH_TIMEOUT_MS);
1082
+ std::env::remove_var(ENV_QUEUE_PROTOCOL);
1083
+ std::env::remove_var(ENV_STREAM_READ_COUNT);
739
1084
 
740
1085
  let config = SelfConsumerConfig::from_env().expect("config should parse");
741
1086
  assert_eq!(config.sem_ttl, Duration::from_secs(DEFAULT_SEM_TTL_SECS));
@@ -751,6 +1096,8 @@ mod tests {
751
1096
  config.flush_timeout,
752
1097
  Duration::from_millis(DEFAULT_FLUSH_TIMEOUT_MS)
753
1098
  );
1099
+ assert_eq!(config.queue_protocol, QueueProtocol::ListV1);
1100
+ assert_eq!(config.stream_read_count, 1);
754
1101
  }
755
1102
 
756
1103
  #[test]
@@ -785,6 +1132,39 @@ mod tests {
785
1132
  assert_eq!(config.max_concurrent, 1);
786
1133
  }
787
1134
 
1135
+ #[test]
1136
+ fn test_stream_queue_protocol_from_env() {
1137
+ let _env = lock_env();
1138
+
1139
+ std::env::set_var(ENV_REDIS_URL, "redis://localhost");
1140
+ std::env::set_var(ENV_TENANT, "test_tenant");
1141
+ std::env::set_var(ENV_FUNCTION_NAME, "test_fn");
1142
+ std::env::set_var(ENV_QUEUE_PROTOCOL, "stream_v1");
1143
+ std::env::set_var(ENV_MAX_CONCURRENT, "128");
1144
+ std::env::remove_var(ENV_STREAM_READ_COUNT);
1145
+
1146
+ let config = SelfConsumerConfig::from_env().expect("config should parse");
1147
+ assert_eq!(config.queue_protocol, QueueProtocol::StreamV1);
1148
+ assert_eq!(config.stream_read_count, DEFAULT_STREAM_READ_COUNT);
1149
+ std::env::remove_var(ENV_QUEUE_PROTOCOL);
1150
+ std::env::remove_var(ENV_STREAM_READ_COUNT);
1151
+ }
1152
+
1153
+ #[test]
1154
+ fn test_stream_read_count_is_configurable_and_capped_by_concurrency() {
1155
+ let _env = lock_env();
1156
+
1157
+ std::env::set_var(ENV_REDIS_URL, "redis://localhost");
1158
+ std::env::set_var(ENV_TENANT, "test_tenant");
1159
+ std::env::set_var(ENV_FUNCTION_NAME, "test_fn");
1160
+ std::env::set_var(ENV_MAX_CONCURRENT, "32");
1161
+ std::env::set_var(ENV_STREAM_READ_COUNT, "128");
1162
+
1163
+ let config = SelfConsumerConfig::from_env().expect("config should parse");
1164
+ assert_eq!(config.stream_read_count, 32);
1165
+ std::env::remove_var(ENV_STREAM_READ_COUNT);
1166
+ }
1167
+
788
1168
  #[test]
789
1169
  fn test_invalid_timeout_values_use_defaults() {
790
1170
  let _env = lock_env();
@@ -894,7 +1274,7 @@ mod tests {
894
1274
 
895
1275
  // Verify notify_handle can be cloned (Arc behavior)
896
1276
  let _notify_handle_clone = notify_handle.clone();
897
- assert_eq!(std::ptr::eq(&notify_handle, &_notify_handle_clone), false);
1277
+ assert!(!std::ptr::eq(&notify_handle, &_notify_handle_clone));
898
1278
  // But they should point to the same underlying Notify
899
1279
  }
900
1280
 
@@ -977,42 +1357,42 @@ mod tests {
977
1357
  // Integration tests for live token tracking and core dispatch logic
978
1358
  #[tokio::test]
979
1359
  async fn test_live_tokens_insert_and_remove() {
980
- let live_tokens: LiveTokens = Arc::new(DashSet::new());
1360
+ let live_calls: LiveCalls = Arc::new(DashMap::new());
981
1361
  let token1 = "lease-token-1";
982
1362
  let token2 = "lease-token-2";
983
1363
 
984
1364
  // Simulate dispatch: register tokens as calls are admitted
985
- live_tokens.insert(token1.to_string());
986
- live_tokens.insert(token2.to_string());
1365
+ live_calls.insert(token1.to_string(), QueueReceipt::ListV1);
1366
+ live_calls.insert(token2.to_string(), QueueReceipt::ListV1);
987
1367
 
988
- assert!(live_tokens.contains(token1));
989
- assert!(live_tokens.contains(token2));
990
- assert_eq!(live_tokens.iter().count(), 2, "should track both tokens");
1368
+ assert!(live_calls.contains_key(token1));
1369
+ assert!(live_calls.contains_key(token2));
1370
+ assert_eq!(live_calls.iter().count(), 2, "should track both tokens");
991
1371
 
992
1372
  // Simulate cleanup: remove as calls complete
993
- live_tokens.remove(token1);
994
- assert!(!live_tokens.contains(token1));
995
- assert!(live_tokens.contains(token2));
996
- assert_eq!(live_tokens.iter().count(), 1);
1373
+ live_calls.remove(token1);
1374
+ assert!(!live_calls.contains_key(token1));
1375
+ assert!(live_calls.contains_key(token2));
1376
+ assert_eq!(live_calls.iter().count(), 1);
997
1377
 
998
1378
  // Final cleanup
999
- live_tokens.remove(token2);
1000
- assert_eq!(live_tokens.iter().count(), 0, "all tokens cleaned up");
1379
+ live_calls.remove(token2);
1380
+ assert_eq!(live_calls.iter().count(), 0, "all tokens cleaned up");
1001
1381
  }
1002
1382
 
1003
1383
  #[tokio::test]
1004
1384
  async fn test_live_tokens_concurrent_access() {
1005
- let live_tokens: LiveTokens = Arc::new(DashSet::new());
1385
+ let live_calls: LiveCalls = Arc::new(DashMap::new());
1006
1386
  let mut handles = vec![];
1007
1387
 
1008
1388
  // Spawn 10 tasks that each insert and remove a token
1009
1389
  for i in 0..10 {
1010
- let tokens = live_tokens.clone();
1390
+ let calls = live_calls.clone();
1011
1391
  let handle = tokio::spawn(async move {
1012
1392
  let token = format!("token-{}", i);
1013
- tokens.insert(token.clone());
1393
+ calls.insert(token.clone(), QueueReceipt::ListV1);
1014
1394
  tokio::time::sleep(Duration::from_millis(1)).await;
1015
- tokens.remove(&token);
1395
+ calls.remove(&token);
1016
1396
  });
1017
1397
  handles.push(handle);
1018
1398
  }
@@ -1024,7 +1404,7 @@ mod tests {
1024
1404
 
1025
1405
  // All tokens should be cleaned up
1026
1406
  assert_eq!(
1027
- live_tokens.iter().count(),
1407
+ live_calls.iter().count(),
1028
1408
  0,
1029
1409
  "concurrent inserts/removes should leave set empty"
1030
1410
  );
@@ -0,0 +1 @@
1
+ __version__ = "1.9.1"
@@ -11,6 +11,9 @@ _TRACER_NAME = "chalk_remote_call"
11
11
  _TRACE_CONTEXT_METADATA_KEYS = ("traceparent", "tracestate", "baggage")
12
12
  _REMOTE_FUNCTION_TRACE_POLICY_ENV_VAR = "CHALK_REMOTE_FUNCTION_TRACE_POLICY"
13
13
  _REMOTE_FUNCTION_TRACE_SAMPLE_RATE_ENV_VAR = "CHALK_REMOTE_FUNCTION_TRACE_SAMPLE_RATE"
14
+ _REMOTE_FUNCTION_TRACE_OVERRIDE_KEY = "x-chalk-remote-function-tracing"
15
+ _TRACE_OVERRIDE_ON = "on"
16
+ _TRACE_OVERRIDE_OFF = "off"
14
17
  _TRACE_POLICY_PARENT_BASED_ALWAYS_OFF = "parentbased_always_off"
15
18
  _TRACE_POLICY_PARENT_BASED_TRACE_ID_RATIO = "parentbased_traceidratio"
16
19
  _TRACE_POLICY_ALWAYS_OFF = "always_off"
@@ -74,7 +77,9 @@ def _trace_policy() -> str | None:
74
77
  return None
75
78
 
76
79
 
77
- def _trace_sample_rate() -> float | None:
80
+ def _trace_sample_rate(force_trace: bool) -> float | None:
81
+ if force_trace:
82
+ return 1.0
78
83
  raw_rate = os.environ.get(_REMOTE_FUNCTION_TRACE_SAMPLE_RATE_ENV_VAR)
79
84
  if raw_rate is None:
80
85
  return None
@@ -136,11 +141,11 @@ def _configure_runtime_tracing(sample_rate: float) -> None:
136
141
  _runtime_tracing_configured = True
137
142
 
138
143
 
139
- def _sample_rate_for_inherited_parent(policy: str) -> float | None:
144
+ def _sample_rate_for_inherited_parent(policy: str, force_trace: bool) -> float | None:
140
145
  if policy == _TRACE_POLICY_PARENT_BASED_ALWAYS_OFF:
141
146
  return 0.0
142
147
  if policy == _TRACE_POLICY_PARENT_BASED_TRACE_ID_RATIO:
143
- return _trace_sample_rate()
148
+ return _trace_sample_rate(force_trace)
144
149
  return None
145
150
 
146
151
 
@@ -148,6 +153,7 @@ def _span_metadata(
148
153
  first_metadata: Mapping[str, Any] | None,
149
154
  traceparent_metadata: Mapping[str, Any] | None,
150
155
  policy: str,
156
+ force_trace: bool,
151
157
  ) -> tuple[Mapping[str, Any] | None, Any | None, Any]:
152
158
  if traceparent_metadata is not None:
153
159
  carrier = _trace_carrier(traceparent_metadata)
@@ -164,7 +170,7 @@ def _span_metadata(
164
170
  if not parent_span_context.is_valid:
165
171
  return None, None, None
166
172
 
167
- sample_rate = _sample_rate_for_inherited_parent(policy)
173
+ sample_rate = _sample_rate_for_inherited_parent(policy, force_trace)
168
174
  if sample_rate is None:
169
175
  return None, None, None
170
176
  _configure_runtime_tracing(sample_rate)
@@ -173,7 +179,7 @@ def _span_metadata(
173
179
  if policy != _TRACE_POLICY_PARENT_BASED_TRACE_ID_RATIO:
174
180
  return None, None, None
175
181
 
176
- sample_rate = _trace_sample_rate()
182
+ sample_rate = _trace_sample_rate(force_trace)
177
183
  if sample_rate is None:
178
184
  return None, None, None
179
185
 
@@ -184,6 +190,17 @@ def _span_metadata(
184
190
  return first_metadata, None, otel_modules
185
191
 
186
192
 
193
+ def _force_sampled_traceparent(metadata: Mapping[str, Any]) -> Mapping[str, Any]:
194
+ parts = str(metadata.get("traceparent") or "").split("-")
195
+ if len(parts) != 4 or len(parts[3]) != 2:
196
+ return metadata
197
+ try:
198
+ parts[3] = f"{int(parts[3], 16) | 1:02x}"
199
+ except ValueError:
200
+ return metadata
201
+ return {**metadata, "traceparent": "-".join(parts)}
202
+
203
+
187
204
  def _always_off_span_metadata(
188
205
  first_metadata: Mapping[str, Any] | None,
189
206
  traceparent_metadata: Mapping[str, Any] | None,
@@ -275,12 +292,21 @@ def remote_function_invocation_span(
275
292
  context_metadata: Any,
276
293
  coalesced_count: int | None = None,
277
294
  ) -> Iterator[None]:
278
- policy = _trace_policy()
295
+ first_metadata, traceparent_metadata = _collect_trace_metadata(context_metadata)
296
+ override = str((first_metadata or {}).get(_REMOTE_FUNCTION_TRACE_OVERRIDE_KEY) or "").strip().lower()
297
+ if override == _TRACE_OVERRIDE_ON:
298
+ policy = _TRACE_POLICY_PARENT_BASED_TRACE_ID_RATIO
299
+ elif override == _TRACE_OVERRIDE_OFF:
300
+ policy = _TRACE_POLICY_ALWAYS_OFF
301
+ else:
302
+ policy = _trace_policy()
279
303
  if policy is None:
280
304
  yield
281
305
  return
282
306
 
283
- first_metadata, traceparent_metadata = _collect_trace_metadata(context_metadata)
307
+ force_trace = override == _TRACE_OVERRIDE_ON
308
+ if force_trace and traceparent_metadata is not None:
309
+ traceparent_metadata = _force_sampled_traceparent(traceparent_metadata)
284
310
  if policy == _TRACE_POLICY_PARENT_BASED_ALWAYS_OFF and traceparent_metadata is None:
285
311
  yield
286
312
  return
@@ -295,6 +321,7 @@ def remote_function_invocation_span(
295
321
  first_metadata,
296
322
  traceparent_metadata,
297
323
  policy,
324
+ force_trace,
298
325
  )
299
326
  tracer = _get_tracer(otel_modules[2]) if otel_modules is not None else None
300
327
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chalk-remote-call-python
3
- Version: 1.8.7
3
+ Version: 1.9.1
4
4
  Summary: Chalk remote call Python runtime interface client
5
5
  Author: Chalk AI, Inc.
6
6
  Project-URL: Homepage, https://chalk.ai
@@ -113,6 +113,7 @@ python -m chalk_remote_call --handler my_handler.handler
113
113
  | Variable | Description |
114
114
  |----------|-------------|
115
115
  | `CHALK_INPUT_ARGS` | Comma-separated list of column names (e.g. `x,y,z`). Renames incoming RecordBatch columns by index. If unset, the original column names are used. Reserved columns (below) are held out of this mapping and keep their own name. |
116
+ | `CHALK_FNQ_QUEUE_PROTOCOL` | Self-consumer work transport: `list_v1` (default/backward compatible) or `stream_v1`. Deployment tooling sets this from immutable scaling-group revision metadata. |
116
117
 
117
118
  #### Reserved columns
118
119
 
@@ -1 +0,0 @@
1
- __version__ = "1.8.7"