use std::io::Write as StdWrite;
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use std::sync::Arc;
use bamboo_plugin::manifest::ServiceInputProtocol;
use serde::Serialize;
use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio::process::Child;
use tokio::sync::{mpsc, RwLock};
use tokio_util::sync::CancellationToken;
pub const DEFAULT_SERVICE_INPUT_QUEUE_CAPACITY: usize = 64;
pub const MAX_SERVICE_INPUT_LINE_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceInputHealth {
Waiting,
Ready,
BrokenStdin,
Stopped,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ServiceInputStatusSnapshot {
pub protocol: ServiceInputProtocol,
#[serde(skip_serializing_if = "Option::is_none")]
pub generation: Option<u64>,
pub health: ServiceInputHealth,
pub queue_capacity: usize,
pub max_line_bytes: usize,
pub accepted_lines: u64,
pub written_lines: u64,
pub dropped_queue_full: u64,
pub dropped_stale_generation: u64,
pub dropped_stopped: u64,
pub dropped_broken_stdin: u64,
pub serialization_failures: u64,
pub oversize_lines: u64,
pub write_failures: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ServiceInputSendError {
#[error("service input generation {generation} is stale")]
StaleGeneration { generation: u64 },
#[error("service input generation {generation} is stopped")]
Stopped { generation: u64 },
#[error("service input generation {generation} has broken stdin")]
BrokenStdin { generation: u64 },
#[error("service input generation {generation} queue is full")]
QueueFull { generation: u64 },
#[error("service input value could not be serialized as JSON")]
Serialization,
#[error("service input line exceeds the {max_bytes}-byte limit")]
Oversize { max_bytes: usize },
}
#[derive(Default)]
struct ServiceInputCounters {
accepted_lines: AtomicU64,
written_lines: AtomicU64,
dropped_queue_full: AtomicU64,
dropped_stale_generation: AtomicU64,
dropped_stopped: AtomicU64,
dropped_broken_stdin: AtomicU64,
serialization_failures: AtomicU64,
oversize_lines: AtomicU64,
write_failures: AtomicU64,
}
fn increment(counter: &AtomicU64) {
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
Some(value.saturating_add(1))
});
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
enum GenerationState {
Active = 0,
Stale = 1,
Stopped = 2,
BrokenStdin = 3,
}
impl GenerationState {
fn from_u8(value: u8) -> Self {
match value {
0 => Self::Active,
1 => Self::Stale,
2 => Self::Stopped,
3 => Self::BrokenStdin,
_ => Self::BrokenStdin,
}
}
}
struct GenerationMeta {
generation: u64,
state: AtomicU8,
counters: Arc<ServiceInputCounters>,
}
impl GenerationMeta {
fn state(&self) -> GenerationState {
GenerationState::from_u8(self.state.load(Ordering::SeqCst))
}
fn mark_broken_stdin(&self) {
let _ = self.state.compare_exchange(
GenerationState::Active as u8,
GenerationState::BrokenStdin as u8,
Ordering::SeqCst,
Ordering::SeqCst,
);
}
fn mark_stale(&self) {
let _ = self
.state
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
(GenerationState::from_u8(current) != GenerationState::Stopped)
.then_some(GenerationState::Stale as u8)
});
}
fn mark_stopped(&self) {
let _ = self
.state
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
(GenerationState::from_u8(current) != GenerationState::Stopped)
.then_some(GenerationState::Stopped as u8)
});
}
}
struct CappedJsonLineWriter {
bytes: Vec<u8>,
max_json_bytes: usize,
exceeded: bool,
}
impl CappedJsonLineWriter {
fn new() -> Self {
Self {
bytes: Vec::with_capacity(1),
max_json_bytes: MAX_SERVICE_INPUT_LINE_BYTES.saturating_sub(1),
exceeded: false,
}
}
fn finish(mut self) -> Vec<u8> {
self.bytes.push(b'\n');
self.bytes
}
}
impl StdWrite for CappedJsonLineWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let remaining = self.max_json_bytes.saturating_sub(self.bytes.len());
if buf.len() > remaining {
self.exceeded = true;
return Err(std::io::Error::other("service input line exceeds limit"));
}
let spare = self.bytes.capacity().saturating_sub(self.bytes.len());
let needed_spare = buf.len().saturating_add(1);
if needed_spare > spare {
self.bytes
.try_reserve_exact(needed_spare - spare)
.map_err(|_| std::io::Error::other("service input allocation failed"))?;
}
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[derive(Clone)]
pub struct ServiceInputSender {
meta: Arc<GenerationMeta>,
tx: mpsc::Sender<Vec<u8>>,
}
impl ServiceInputSender {
pub fn generation(&self) -> u64 {
self.meta.generation
}
fn reject_for_state(&self) -> Result<(), ServiceInputSendError> {
let generation = self.generation();
match self.meta.state() {
GenerationState::Active => Ok(()),
GenerationState::Stale => {
increment(&self.meta.counters.dropped_stale_generation);
Err(ServiceInputSendError::StaleGeneration { generation })
}
GenerationState::Stopped => {
increment(&self.meta.counters.dropped_stopped);
Err(ServiceInputSendError::Stopped { generation })
}
GenerationState::BrokenStdin => {
increment(&self.meta.counters.dropped_broken_stdin);
Err(ServiceInputSendError::BrokenStdin { generation })
}
}
}
pub fn try_send<T>(&self, value: &T) -> Result<(), ServiceInputSendError>
where
T: Serialize + ?Sized,
{
self.reject_for_state()?;
let mut writer = CappedJsonLineWriter::new();
if serde_json::to_writer(&mut writer, value).is_err() {
if writer.exceeded {
increment(&self.meta.counters.oversize_lines);
return Err(ServiceInputSendError::Oversize {
max_bytes: MAX_SERVICE_INPUT_LINE_BYTES,
});
}
increment(&self.meta.counters.serialization_failures);
return Err(ServiceInputSendError::Serialization);
}
let line = writer.finish();
self.reject_for_state()?;
match self.tx.try_send(line) {
Ok(()) => {
increment(&self.meta.counters.accepted_lines);
Ok(())
}
Err(mpsc::error::TrySendError::Full(_)) => {
increment(&self.meta.counters.dropped_queue_full);
Err(ServiceInputSendError::QueueFull {
generation: self.generation(),
})
}
Err(mpsc::error::TrySendError::Closed(_)) => {
self.meta.mark_broken_stdin();
self.reject_for_state()
}
}
}
#[cfg(test)]
pub(super) fn remaining_capacity(&self) -> usize {
self.tx.capacity()
}
}
struct ActiveServiceInput {
sender: ServiceInputSender,
cancel: CancellationToken,
}
pub(super) struct ServiceInputRuntime {
service_id: String,
next_generation: Arc<AtomicU64>,
counters: Arc<ServiceInputCounters>,
active: RwLock<Option<ActiveServiceInput>>,
}
impl ServiceInputRuntime {
pub(super) fn new(service_id: String, next_generation: Arc<AtomicU64>) -> Self {
Self {
service_id,
next_generation,
counters: Arc::new(ServiceInputCounters::default()),
active: RwLock::new(None),
}
}
pub(super) async fn sender(&self) -> Option<ServiceInputSender> {
self.active
.read()
.await
.as_ref()
.map(|active| active.sender.clone())
}
pub(super) async fn bind_child(
&self,
child: &mut Child,
) -> Result<BoundServiceInput, ServiceInputBindError> {
let stdin = child
.stdin
.take()
.ok_or(ServiceInputBindError::MissingStdinPipe)?;
self.bind_writer(stdin, DEFAULT_SERVICE_INPUT_QUEUE_CAPACITY)
.await
}
async fn bind_writer<W>(
&self,
writer: W,
queue_capacity: usize,
) -> Result<BoundServiceInput, ServiceInputBindError>
where
W: AsyncWrite + Unpin + Send + 'static,
{
let mut active = self.active.write().await;
if active.is_some() {
return Err(ServiceInputBindError::GenerationAlreadyBound);
}
let generation = self
.next_generation
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
current.checked_add(1)
})
.map(|previous| previous + 1)
.map_err(|_| ServiceInputBindError::GenerationExhausted)?;
let meta = Arc::new(GenerationMeta {
generation,
state: AtomicU8::new(GenerationState::Active as u8),
counters: self.counters.clone(),
});
let cancel = CancellationToken::new();
let (tx, rx) = mpsc::channel(queue_capacity.max(1));
let sender = ServiceInputSender {
meta: meta.clone(),
tx,
};
let writer_cancel = cancel.clone();
let service_id = self.service_id.clone();
let task = tokio::spawn(run_writer(service_id, meta, writer_cancel, writer, rx));
*active = Some(ActiveServiceInput {
sender: sender.clone(),
cancel: cancel.clone(),
});
Ok(BoundServiceInput {
sender,
cancel,
task: Some(task),
})
}
pub(super) async fn stop_active(&self) {
let active = self.active.write().await.take();
if let Some(active) = active {
active.sender.meta.mark_stopped();
active.cancel.cancel();
}
}
async fn retire_generation(&self, generation: u64, stopped: bool) {
let mut active = self.active.write().await;
if active
.as_ref()
.is_some_and(|active| active.sender.generation() == generation)
{
let active = active.take().expect("checked active generation");
if stopped {
active.sender.meta.mark_stopped();
} else {
active.sender.meta.mark_stale();
}
active.cancel.cancel();
}
}
pub(super) async fn snapshot(&self, stopped: bool) -> ServiceInputStatusSnapshot {
let active = self.active.read().await;
let (generation, health) = match active.as_ref() {
Some(active) => (
Some(active.sender.generation()),
match active.sender.meta.state() {
GenerationState::Active => ServiceInputHealth::Ready,
GenerationState::BrokenStdin => ServiceInputHealth::BrokenStdin,
GenerationState::Stale => ServiceInputHealth::Waiting,
GenerationState::Stopped => ServiceInputHealth::Stopped,
},
),
None if stopped => (None, ServiceInputHealth::Stopped),
None => (None, ServiceInputHealth::Waiting),
};
ServiceInputStatusSnapshot {
protocol: ServiceInputProtocol::NdjsonV1,
generation,
health,
queue_capacity: DEFAULT_SERVICE_INPUT_QUEUE_CAPACITY,
max_line_bytes: MAX_SERVICE_INPUT_LINE_BYTES,
accepted_lines: self.counters.accepted_lines.load(Ordering::Relaxed),
written_lines: self.counters.written_lines.load(Ordering::Relaxed),
dropped_queue_full: self.counters.dropped_queue_full.load(Ordering::Relaxed),
dropped_stale_generation: self
.counters
.dropped_stale_generation
.load(Ordering::Relaxed),
dropped_stopped: self.counters.dropped_stopped.load(Ordering::Relaxed),
dropped_broken_stdin: self.counters.dropped_broken_stdin.load(Ordering::Relaxed),
serialization_failures: self.counters.serialization_failures.load(Ordering::Relaxed),
oversize_lines: self.counters.oversize_lines.load(Ordering::Relaxed),
write_failures: self.counters.write_failures.load(Ordering::Relaxed),
}
}
#[cfg(test)]
pub(super) async fn bind_writer_for_test<W>(
&self,
writer: W,
queue_capacity: usize,
) -> Result<BoundServiceInput, ServiceInputBindError>
where
W: AsyncWrite + Unpin + Send + 'static,
{
self.bind_writer(writer, queue_capacity).await
}
#[cfg(test)]
pub(super) async fn bind_child_for_test(
&self,
child: &mut Child,
queue_capacity: usize,
) -> Result<BoundServiceInput, ServiceInputBindError> {
let stdin = child
.stdin
.take()
.ok_or(ServiceInputBindError::MissingStdinPipe)?;
self.bind_writer(stdin, queue_capacity).await
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub(super) enum ServiceInputBindError {
#[error("spawned NDJSON service has no stdin pipe")]
MissingStdinPipe,
#[error("service input generation is already bound")]
GenerationAlreadyBound,
#[error("service input generation space is exhausted")]
GenerationExhausted,
}
pub(super) struct BoundServiceInput {
sender: ServiceInputSender,
cancel: CancellationToken,
task: Option<tokio::task::JoinHandle<()>>,
}
impl BoundServiceInput {
pub(super) fn generation(&self) -> u64 {
self.sender.generation()
}
pub(super) async fn close(mut self, runtime: &ServiceInputRuntime, stopped: bool) {
runtime.retire_generation(self.generation(), stopped).await;
if stopped {
self.sender.meta.mark_stopped();
} else {
self.sender.meta.mark_stale();
}
self.cancel.cancel();
if let Some(task) = self.task.take() {
let _ = task.await;
}
}
}
impl Drop for BoundServiceInput {
fn drop(&mut self) {
self.sender.meta.mark_stale();
self.cancel.cancel();
if let Some(task) = self.task.take() {
task.abort();
}
}
}
async fn run_writer<W>(
service_id: String,
meta: Arc<GenerationMeta>,
cancel: CancellationToken,
mut writer: W,
mut rx: mpsc::Receiver<Vec<u8>>,
) where
W: AsyncWrite + Unpin,
{
loop {
let line = tokio::select! {
biased;
_ = cancel.cancelled() => break,
line = rx.recv() => match line {
Some(line) => line,
None => break,
},
};
let result = tokio::select! {
biased;
_ = cancel.cancelled() => break,
result = async {
writer.write_all(&line).await?;
writer.flush().await
} => result,
};
match result {
Ok(()) => increment(&meta.counters.written_lines),
Err(error) => {
increment(&meta.counters.write_failures);
meta.mark_broken_stdin();
tracing::warn!(
service_id = %service_id,
generation = meta.generation,
error_kind = ?error.kind(),
"service NDJSON stdin writer stopped"
);
break;
}
}
}
rx.close();
}