Skip to content

Commit da73742

Browse files
userFRMclaude
andauthored
feat(streaming): expose a configurable wait strategy across every binding (#806)
* feat(streaming): expose a configurable wait strategy across every binding The streaming event-ring consumer applies a wait strategy on every ring-empty poll; this was previously fixed. A `StreamingWaitStrategy` preset now selects the latency-vs-CPU trade-off across every binding: `LowLatency` (the default, which reproduces the previous fixed behaviour byte-for-byte — spin, yield, `spin_loop` hint, never sleeps), `Balanced` (a short spin then a brief timed park), `Efficient` (a longer park for the lowest idle CPU), and `BusySpin` (pure spin, pinning a core). The spin, yield, and park counts tune independently and are clamped to sane bounds. The preset enum plus the numeric `wait_spin_iters` / `wait_yield_iters` / `wait_park_us` knobs are the FFI-safe form of the override, exposed identically on Python, TypeScript, C++, and the C ABI (with `THETADATADX_WAIT_*` selectors), and enforced cross-binding by the parity guard. Rust additionally gains a zero-cost generic `StreamingClient::for_each_with_wait_strategy` that accepts any user `WaitStrategy` impl; that override is Rust-only by design, because `wait_for` fires on every ring-empty poll and routing a per-poll callback across the C ABI, the CPython lock, or the JavaScript event loop would be a latency regression rather than a tuning knob. The same surface adds an optional `consumer_cpu` knob that pins the tick-consumer drain thread to a CPU core for deterministic, low-jitter delivery; `None` (a negative sentinel on the C ABI) is the default and leaves the thread under the OS scheduler, so behaviour is unchanged unless a client opts in. An out-of-range or offline core is a best-effort no-op at the affinity layer rather than a hard error. The config-derived strategy threads through the builder into both the ring build site and the consumer-side blocking drain; the disruptor builder erases the wait-strategy type after build, so the poller and producer return types are unchanged and the ring API stays stable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(ffi): name the per-call SAFETY invariant in the wait-strategy test The wait-strategy C ABI round-trip test repeated a stamped "see the module-level note" SAFETY comment across several unsafe blocks, which the SAFETY-comment hygiene gate rejects. Rewrite each to name the actual invariant — the live, unfreed cfg handle and the valid stack out-params for that specific call. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(streaming): pin the consumer core on the wait-strategy override path and de-flake the preset test The Rust-only for_each_with_wait_strategy override drained the ring without applying the configured consumer-core affinity, so a caller that set consumer_cpu and used the override lost the pin; call pin_consumer_once at its drain entry like the other drain paths so the config knob applies consistently regardless of which drive API is used. Replace the fragile absolute upper-bound timing assertion on the never-sleeping low-latency wait_for with a relative comparison against the Balanced preset's real park, so the test does not flake when a loaded host stretches the yield phase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: remove a third-party project reference from the historical notes Drop a parenthetical naming an external project's repository layout from the historical changelog, its rendered mirror, and the matching release note. The repository's structure stands on its own description; referencing another project's layout adds nothing and does not belong in public release prose. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: correct the wait-strategy override doc link and sync tool lockfiles Point the streaming wait-strategy docstring at the real `for_each_with_wait_strategy` method (the rustdoc intra-doc-link gate rejected the stale `start_streaming_with_wait_strategy` name), and resync the tools/mcp and tools/server lockfiles so the excluded-workspace `--locked` builds match their manifests after the new affinity dependency. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 61c663a commit da73742

33 files changed

Lines changed: 2128 additions & 53 deletions

File tree

.github/release-notes/v9.1.0.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ the user callback directly from the event-delivery thread.
7474
TypeScript public-surface drift relative to the runtime SDKs now
7575
surfaces as a pre-merge agreement failure rather than going
7676
unnoticed.
77-
- Repo hygiene pass. Root tree trimmed to standard institutional
78-
shape (matches the databento-rs layout): moved `ROADMAP.md`
77+
- Repo hygiene pass. Root tree trimmed to a standard institutional
78+
layout: moved `ROADMAP.md`
7979
`docs/`, moved `config.default.toml` into the `thetadatadx` crate,
8080
deleted unused `cliff.toml`. Architecture ADRs inlined into
8181
source-code comments at their relevant locations;

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939

4040
### Added
4141

42+
- Configurable streaming latency tuning across every binding: a `StreamingWaitStrategy` event-ring wait-strategy preset (`LowLatency` default, which preserves the previous fixed behaviour, plus `Balanced`, `Efficient`, and `BusySpin`) with independent spin / yield / park tuning, and an optional `consumer_cpu` knob that pins the tick-consumer thread to a CPU core (`None` default leaves it under the OS scheduler); Rust additionally exposes a zero-cost generic `StreamingClient::for_each_with_wait_strategy` override that accepts any user `WaitStrategy` impl, with the preset enum plus numeric tuning as the FFI-safe form on Python (`wait_strategy` / `wait_spin_iters` / `wait_yield_iters` / `wait_park_us` / `consumer_cpu`), TypeScript (`waitStrategy` / `setWaitStrategy` and the matching numeric and `consumerCpu` setters/getters), C++ (`set_wait_strategy` / `get_wait_strategy` and the numeric / `consumer_cpu` forwarders), and the C ABI (`thetadatadx_config_set_wait_strategy` with `THETADATADX_WAIT_*` selectors, the numeric setters/getters, and `thetadatadx_config_set_consumer_cpu` with a negative `THETADATADX_CONSUMER_CPU_UNPINNED` sentinel).
4243
- Epoch-instant accessors on every row that carries `date` plus a milliseconds-of-day column, computed on read (raw integer fields stay primary): Rust methods and Python properties named `timestamp_ms` for the bare `ms_of_day` column and `<prefix>_timestamp_ms` for prefixed columns (`created_timestamp_ms`, `last_trade_timestamp_ms`, `underlying_timestamp_ms`, `quote_timestamp_ms`), the C function `thetadatadx_timestamp_ms(date, ms_of_day)`, and the C++ `thetadatadx::timestamp_ms` wrapper. All return Unix epoch milliseconds (UTC, DST-aware) and signal absent dates (`None` / `-1`).
4344
- `thetadatadx::time::date_ms_to_epoch_ms` — the DST-aware inverse of the epoch-to-Eastern split, shared by every accessor above.
4445
- `thetadatadx::CalendarStatus` — the exported calendar day-type enum with `as_str()` / `from_wire_text()` / `from_code()` / `is_open()`.
@@ -617,8 +618,8 @@ code does not change.
617618
surfaces as a pre-merge agreement failure rather than going
618619
unnoticed until a downstream consumer hit the missing / extra
619620
field.
620-
- Repo hygiene pass. Root tree trimmed to standard institutional shape
621-
(matches the databento-rs layout): moved `ROADMAP.md``docs/`,
621+
- Repo hygiene pass. Root tree trimmed to a standard institutional
622+
layout: moved `ROADMAP.md``docs/`,
622623
moved `config.default.toml` into the `thetadatadx` crate, deleted unused
623624
`cliff.toml`. Architecture ADRs inlined into source-code comments at
624625
their relevant locations; `docs/architecture/` removed. Generated

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/thetadatadx/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@ regex = { version = "1.12.3", optional = true }
188188
# Lock-free ring buffer for FPSS event dispatch # VOCAB-OK: crate name in dep
189189
disruptor = "4.2.0" # VOCAB-OK: actual dep crate name
190190

191+
# CPU core pinning for the streaming event-ring consumer thread
192+
# (optional `consumer_cpu` tuning knob). Already in the dependency tree
193+
# via disruptor; named directly so the drain loop can pin the real
194+
# consumer thread, which the disruptor builder cannot reach in polling
195+
# mode. # VOCAB-OK: crate name in dep
196+
core_affinity = "0.8.1" # VOCAB-OK: actual dep crate name
197+
191198
# Optional DataFrame / Arrow extension traits (`frames` module).
192199
# polars: minimal feature set — just enough to build DataFrame from Series.
193200
# No lazy, no I/O, no parquet, no SQL, no compute kernels.

crates/thetadatadx/benches/streaming_channels.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ use thetadatadx::fpss::{StreamControl, StreamEvent};
9191
// from the crate under test, not user-facing prose.
9292
#[cfg(feature = "__test-helpers")]
9393
use thetadatadx::fpss::__test_internals::{
94-
build_poller_producer, Polling, RingCursors, RingProducer,
94+
build_poller_producer, AdaptiveWaitStrategy, Polling, RingCursors, RingProducer,
9595
};
9696

9797
/// Number of events shipped through the pipeline per criterion sample.
@@ -320,7 +320,11 @@ fn run_disruptor_production_ctor() -> (u64, u64) {
320320
// Shared occupancy cursors the production adapter records into — the
321321
// exact pair `StreamingClient::ring_occupancy` samples in the live client.
322322
let cursors = Arc::new(RingCursors::new());
323-
let (mut producer, mut poller) = build_poller_producer(RING_SIZE, Arc::clone(&cursors));
323+
let (mut producer, mut poller) = build_poller_producer(
324+
RING_SIZE,
325+
Arc::clone(&cursors),
326+
AdaptiveWaitStrategy::low_latency(),
327+
);
324328

325329
// Producer thread: publish via the instrumented adapter. Each
326330
// successful `try_publish` records the published sequence into the

crates/thetadatadx/src/client.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,13 @@ impl Client {
332332
let client = StreamingClient::builder(&self.creds, &config.streaming.hosts)
333333
.ring_size(config.streaming.ring_size)
334334
.flush_mode(config.streaming.flush_mode)
335+
.wait_strategy(config.streaming.wait_strategy)
336+
.wait_strategy_tuning(
337+
config.streaming.wait_spin_iters,
338+
config.streaming.wait_yield_iters,
339+
config.streaming.wait_park_us,
340+
)
341+
.consumer_cpu(config.streaming.consumer_cpu)
335342
.reconnect_policy(config.reconnect.policy.clone())
336343
.reconnect_wait_ms(config.reconnect.wait_ms)
337344
.reconnect_wait_max_ms(config.reconnect.wait_max_ms)

crates/thetadatadx/src/config/fpss.rs

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,92 @@ impl HostSelectionPolicy {
6262
}
6363
}
6464

65+
/// Wait strategy for the streaming event-ring consumer — the
66+
/// latency-vs-CPU knob applied on every ring-empty poll.
67+
///
68+
/// The consumer drains the lock-free event ring; when it momentarily
69+
/// finds the ring empty it runs this strategy before re-checking. The
70+
/// preset picks a point on the latency-vs-CPU curve:
71+
///
72+
/// - [`StreamingWaitStrategy::LowLatency`] (default): spin then yield
73+
/// then a `spin_loop` hint, never sleeps. Lowest latency, highest
74+
/// idle CPU. Reproduces the historical fixed behaviour.
75+
/// - [`StreamingWaitStrategy::Balanced`]: short spin then a brief park.
76+
/// Low idle CPU, ~one park interval of added tail latency.
77+
/// - [`StreamingWaitStrategy::Efficient`]: minimal spin then a longer
78+
/// park. Lowest idle CPU.
79+
/// - [`StreamingWaitStrategy::BusySpin`]: pure spin, no yield or sleep.
80+
/// Absolute minimum latency; pins a core while idle.
81+
///
82+
/// The spin / yield / park counts are tuned independently via
83+
/// [`StreamingConfig::wait_spin_iters`], [`StreamingConfig::wait_yield_iters`],
84+
/// and [`StreamingConfig::wait_park_us`].
85+
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
86+
#[non_exhaustive]
87+
pub enum StreamingWaitStrategy {
88+
/// Spin, yield, then a `spin_loop` hint; never sleeps. Lowest
89+
/// latency, highest idle CPU. Default.
90+
#[default]
91+
LowLatency,
92+
/// Short spin then a brief timed park. Low idle CPU, slightly higher
93+
/// tail latency.
94+
Balanced,
95+
/// Minimal spin then a longer timed park. Lowest idle CPU.
96+
Efficient,
97+
/// Pure spin, no yield or sleep. Absolute minimum latency; pins a
98+
/// core while the ring is idle.
99+
BusySpin,
100+
}
101+
102+
impl StreamingWaitStrategy {
103+
/// Canonical lowercase string for this strategy, matching the
104+
/// cross-binding encoding.
105+
#[must_use]
106+
pub fn as_str(self) -> &'static str {
107+
match self {
108+
Self::LowLatency => "low_latency",
109+
Self::Balanced => "balanced",
110+
Self::Efficient => "efficient",
111+
Self::BusySpin => "busy_spin",
112+
}
113+
}
114+
115+
/// Parse the cross-binding string encoding (case-insensitive).
116+
/// Returns `None` for unrecognised input.
117+
#[must_use]
118+
pub fn parse(s: &str) -> Option<Self> {
119+
match s.to_ascii_lowercase().as_str() {
120+
"low_latency" => Some(Self::LowLatency),
121+
"balanced" => Some(Self::Balanced),
122+
"efficient" => Some(Self::Efficient),
123+
"busy_spin" => Some(Self::BusySpin),
124+
_ => None,
125+
}
126+
}
127+
}
128+
129+
impl std::fmt::Display for StreamingWaitStrategy {
130+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131+
f.write_str(self.as_str())
132+
}
133+
}
134+
135+
impl std::str::FromStr for StreamingWaitStrategy {
136+
type Err = crate::error::Error;
137+
138+
fn from_str(s: &str) -> Result<Self, Self::Err> {
139+
Self::parse(s).ok_or_else(|| {
140+
crate::error::Error::config_invalid(
141+
"streaming.wait_strategy",
142+
format!(
143+
"wait_strategy must be one of \
144+
\"low_latency\", \"balanced\", \"efficient\", \"busy_spin\"; got {s:?}"
145+
),
146+
)
147+
})
148+
}
149+
}
150+
65151
/// Streaming client tuning.
66152
///
67153
/// The timing knobs (`timeout_ms`, `ping_interval_ms`,
@@ -197,6 +283,46 @@ pub struct StreamingConfig {
197283
/// latency, higher syscall overhead.
198284
pub flush_mode: StreamingFlushMode,
199285

286+
/// Wait strategy for the event-ring consumer — the latency-vs-CPU
287+
/// knob applied on every ring-empty poll.
288+
///
289+
/// - [`StreamingWaitStrategy::LowLatency`] (default): never sleeps,
290+
/// lowest latency, highest idle CPU.
291+
/// - [`StreamingWaitStrategy::Balanced`]: brief park, low idle CPU.
292+
/// - [`StreamingWaitStrategy::Efficient`]: longer park, lowest idle CPU.
293+
/// - [`StreamingWaitStrategy::BusySpin`]: pure spin, pins a core.
294+
pub wait_strategy: StreamingWaitStrategy,
295+
296+
/// Spin iterations the wait strategy busy-waits before yielding /
297+
/// parking. Higher values trade idle CPU for lower wake latency.
298+
/// Default `100`. Clamped to a sane upper bound when applied.
299+
pub wait_spin_iters: u32,
300+
301+
/// `thread::yield_now()` iterations after the spin phase, before any
302+
/// park. Higher values smooth brief inter-burst gaps at slightly
303+
/// higher idle CPU. Default `10`. Clamped when applied.
304+
pub wait_yield_iters: u32,
305+
306+
/// Park interval (microseconds) for the parking wait strategies
307+
/// ([`StreamingWaitStrategy::Balanced`] /
308+
/// [`StreamingWaitStrategy::Efficient`]). Larger values lower idle
309+
/// CPU at the cost of added tail latency; inert under
310+
/// [`StreamingWaitStrategy::LowLatency`] /
311+
/// [`StreamingWaitStrategy::BusySpin`], which never sleep. Default
312+
/// `50`. Clamped when applied.
313+
pub wait_park_us: u64,
314+
315+
/// Optional CPU core to pin the streaming event-ring consumer thread
316+
/// to.
317+
///
318+
/// `None` (default) leaves the consumer under the OS scheduler — the
319+
/// historical behaviour. `Some(core_id)` pins the tick-consumer
320+
/// thread to that core for deterministic, low-jitter delivery; pair
321+
/// with an isolated core (e.g. `isolcpus`) for best results. An
322+
/// out-of-range or offline core is a best-effort no-op at the
323+
/// affinity layer (a `warn` is logged) rather than a hard error.
324+
pub consumer_cpu: Option<usize>,
325+
200326
/// Whether to derive OHLCVC bars locally from trade events.
201327
///
202328
/// When `true` (default), the streaming client emits derived `StreamData::Ohlcvc`
@@ -228,9 +354,39 @@ impl StreamingConfig {
228354
keepalive_interval_secs: 2,
229355
keepalive_retries: 2,
230356
flush_mode: StreamingFlushMode::Batched,
357+
wait_strategy: StreamingWaitStrategy::LowLatency,
358+
wait_spin_iters: 100,
359+
wait_yield_iters: 10,
360+
wait_park_us: 50,
361+
consumer_cpu: None,
231362
derive_ohlcvc: true,
232363
}
233364
}
365+
366+
/// Build the event-ring consumer wait strategy from the configured
367+
/// mode and tuning knobs.
368+
///
369+
/// The [`StreamingWaitStrategy::LowLatency`] default with the default
370+
/// tuning reproduces the historical fixed FPSS strategy byte-for-byte
371+
/// (100 spins / 10 yields / trailing `spin_loop` hint, never sleeps).
372+
/// Out-of-range tuning is clamped to the wait strategy's documented
373+
/// bounds.
374+
#[must_use]
375+
pub(crate) fn build_wait_strategy(&self) -> crate::fpss::ring::AdaptiveWaitStrategy {
376+
use crate::fpss::ring::{AdaptiveWaitStrategy, WaitMode};
377+
let mode = match self.wait_strategy {
378+
StreamingWaitStrategy::LowLatency => WaitMode::LowLatency,
379+
StreamingWaitStrategy::Balanced => WaitMode::Balanced,
380+
StreamingWaitStrategy::Efficient => WaitMode::Efficient,
381+
StreamingWaitStrategy::BusySpin => WaitMode::BusySpin,
382+
};
383+
AdaptiveWaitStrategy::from_mode(
384+
mode,
385+
self.wait_spin_iters,
386+
self.wait_yield_iters,
387+
self.wait_park_us,
388+
)
389+
}
234390
}
235391

236392
/// Validation bounds for the wired streaming knobs. Out-of-range values
@@ -250,6 +406,19 @@ pub mod bounds {
250406
pub const KEEPALIVE_INTERVAL_SECS: std::ops::RangeInclusive<u64> = 1..=75;
251407
/// Allowed range for [`super::StreamingConfig::keepalive_retries`].
252408
pub const KEEPALIVE_RETRIES: std::ops::RangeInclusive<u32> = 1..=10;
409+
/// Allowed range for [`super::StreamingConfig::wait_spin_iters`]. The
410+
/// ceiling caps a misconfiguration from turning the spin phase into a
411+
/// multi-millisecond busy-wait.
412+
pub const WAIT_SPIN_ITERS: std::ops::RangeInclusive<u32> =
413+
0..=crate::fpss::ring::AdaptiveWaitStrategy::MAX_SPIN_ITERS;
414+
/// Allowed range for [`super::StreamingConfig::wait_yield_iters`].
415+
pub const WAIT_YIELD_ITERS: std::ops::RangeInclusive<u32> =
416+
0..=crate::fpss::ring::AdaptiveWaitStrategy::MAX_YIELD_ITERS;
417+
/// Allowed range for [`super::StreamingConfig::wait_park_us`], in
418+
/// microseconds. The ceiling caps a misconfiguration from parking the
419+
/// consumer for seconds at a time.
420+
pub const WAIT_PARK_US: std::ops::RangeInclusive<u64> =
421+
0..=crate::fpss::ring::AdaptiveWaitStrategy::MAX_PARK_US;
253422
}
254423

255424
impl Default for StreamingConfig {
@@ -274,6 +443,11 @@ mod tests {
274443
assert_eq!(cfg.keepalive_retries, 2);
275444
assert_eq!(cfg.host_selection, HostSelectionPolicy::Shuffled);
276445
assert_eq!(cfg.host_shuffle_seed, None);
446+
assert_eq!(cfg.wait_strategy, StreamingWaitStrategy::LowLatency);
447+
assert_eq!(cfg.wait_spin_iters, 100);
448+
assert_eq!(cfg.wait_yield_iters, 10);
449+
assert_eq!(cfg.wait_park_us, 50);
450+
assert_eq!(cfg.consumer_cpu, None);
277451
// Kernel-side half-open detection at the defaults:
278452
// idle + interval * retries = 5 + 2*2 = 9 seconds.
279453
let detection = cfg.keepalive_idle_secs
@@ -299,4 +473,58 @@ mod tests {
299473
HostSelectionPolicy::Shuffled
300474
);
301475
}
476+
477+
#[test]
478+
fn wait_strategy_string_round_trip() {
479+
use std::str::FromStr;
480+
for s in [
481+
StreamingWaitStrategy::LowLatency,
482+
StreamingWaitStrategy::Balanced,
483+
StreamingWaitStrategy::Efficient,
484+
StreamingWaitStrategy::BusySpin,
485+
] {
486+
assert_eq!(StreamingWaitStrategy::parse(s.as_str()), Some(s));
487+
assert_eq!(s.to_string(), s.as_str());
488+
assert_eq!(StreamingWaitStrategy::from_str(s.as_str()).unwrap(), s);
489+
}
490+
assert_eq!(
491+
StreamingWaitStrategy::parse("BUSY_SPIN"),
492+
Some(StreamingWaitStrategy::BusySpin)
493+
);
494+
assert_eq!(StreamingWaitStrategy::parse("bogus"), None);
495+
assert!(StreamingWaitStrategy::from_str("bogus").is_err());
496+
assert_eq!(
497+
StreamingWaitStrategy::default(),
498+
StreamingWaitStrategy::LowLatency
499+
);
500+
}
501+
502+
#[test]
503+
fn build_wait_strategy_default_is_low_latency() {
504+
let cfg = StreamingConfig::production_defaults();
505+
// The default config must reproduce the historical fixed strategy.
506+
let built = cfg.build_wait_strategy();
507+
let expected = crate::fpss::ring::AdaptiveWaitStrategy::low_latency();
508+
// Both are LowLatency with the 100/10 tuning; compare the public
509+
// preset constructor against the config-built strategy via their
510+
// observable `wait_for` (LowLatency never sleeps).
511+
use disruptor::wait_strategies::WaitStrategy;
512+
let t0 = std::time::Instant::now();
513+
built.wait_for(0);
514+
expected.wait_for(0);
515+
// LowLatency never parks, so two calls stay well under a
516+
// millisecond — guards against the default silently parking.
517+
assert!(t0.elapsed() < std::time::Duration::from_millis(5));
518+
}
519+
520+
#[test]
521+
fn build_wait_strategy_clamps_out_of_range_tuning() {
522+
let mut cfg = StreamingConfig::production_defaults();
523+
cfg.wait_strategy = StreamingWaitStrategy::Balanced;
524+
cfg.wait_spin_iters = u32::MAX;
525+
cfg.wait_yield_iters = u32::MAX;
526+
cfg.wait_park_us = u64::MAX;
527+
// Must not panic; clamping happens inside `from_mode`.
528+
let _ = cfg.build_wait_strategy();
529+
}
302530
}

0 commit comments

Comments
 (0)