use serde::{Deserialize, Serialize};
use tocat_api::{
BuildCtx, ByteSize, Ctx, Interval, Plugin, PluginError, PluginFactory, Result, Stage,
};
pub const BLOCK: &str = "block";
const DEFAULT_BYTESIZE: usize = 4096;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
pub struct BlockConfig {
pub size: ByteSize,
pub flush: Option<Interval>,
pub pad: bool,
}
impl Default for BlockConfig {
fn default() -> Self {
Self {
size: ByteSize(DEFAULT_BYTESIZE),
flush: None,
pad: false,
}
}
}
pub struct Block {
buf: Vec<u8>,
size: usize,
flush: Option<std::time::Duration>,
pad: bool,
}
impl Block {
fn immediate(&self) -> bool {
self.flush.is_some_and(|dur| dur.is_zero())
}
fn emit(&mut self, ctx: &mut Ctx<'_>) {
if self.buf.is_empty() {
return;
}
if self.pad {
self.buf.resize(self.size, 0);
}
ctx.forward(&self.buf);
ctx.boundary();
self.buf.clear();
}
}
impl Plugin for Block {
fn name(&self) -> &str {
BLOCK
}
fn tick_interval(&self) -> Option<std::time::Duration> {
self.flush.filter(|dur| !dur.is_zero())
}
fn on_tick(&mut self, ctx: &mut Ctx<'_>) -> Result<()> {
self.emit(ctx);
Ok(())
}
fn on_bytes(&mut self, ctx: &mut Ctx<'_>, mut input: &[u8]) -> Result<()> {
while !input.is_empty() {
if self.buf.is_empty() {
ctx.rearm();
}
let n = input.len().min(self.size - self.buf.len());
self.buf.extend_from_slice(&input[..n]);
input = &input[n..];
if self.buf.len() == self.size {
self.emit(ctx);
}
}
if self.immediate() {
self.emit(ctx);
}
Ok(())
}
fn on_eof(&mut self, ctx: &mut Ctx<'_>) -> Result<()> {
self.emit(ctx);
Ok(())
}
}
pub struct BlockFactory;
impl PluginFactory for BlockFactory {
fn name(&self) -> &str {
BLOCK
}
fn description(&self) -> &str {
"Accumulate bytes into fixed-size blocks"
}
fn build(&self, ctx: &mut BuildCtx<'_>) -> Result<Stage> {
let config: BlockConfig = ctx.config()?;
let size = config.size.bytes();
if size == 0 {
return Err(PluginError::config(
self.name(),
"size must be greater than zero",
));
}
let mut buf = Vec::new();
buf.try_reserve_exact(size).map_err(|e| {
PluginError::runtime(self.name(), format!("could not reserve {size} bytes: {e}"))
})?;
Ok(Stage::filter(Block {
buf,
size,
flush: config.flush.map(Interval::duration),
pad: config.pad,
}))
}
}
#[cfg(test)]
mod tests {
use serde_json::{Map, Value, json};
use tocat_api::{
Boundaries, ChannelId, ChannelTarget, Direction, EffectSink, Emission, Emit, HostBuilder,
LogLevel, PipelineMeta, StageInfo,
};
use super::*;
struct NoHost;
impl HostBuilder for NoHost {
fn open_channel(&mut self, _target: ChannelTarget) -> Result<ChannelId> {
unreachable!("block opens no side channels")
}
}
struct Silent;
impl EffectSink for Silent {
fn write(&mut self, _channel: ChannelId, _bytes: &[u8]) {}
fn log(&mut self, _level: LogLevel, _stage: &str, _message: &str) {}
}
fn meta() -> PipelineMeta {
PipelineMeta::new(Direction::SourceToSink, "src", "sink")
}
fn build(value: Value) -> Result<Box<dyn Plugin>> {
let meta = meta();
let map: Map<String, Value> = match value {
Value::Object(map) => map,
other => unreachable!("config must be an object, got {other}"),
};
let mut host = NoHost;
let stage = StageInfo {
index: 0,
total: 1,
name: BLOCK,
upstream: "src",
downstream: "sink",
};
let mut ctx = BuildCtx::new(BLOCK, &map, &meta, stage, &mut host);
match BlockFactory.build(&mut ctx)? {
Stage::Filter(plugin) => Ok(plugin),
Stage::External(_) => unreachable!("block is a filter"),
}
}
fn built(value: Value) -> Box<dyn Plugin> {
build(value).expect("build")
}
fn units(emission: &Emission) -> Vec<Vec<u8>> {
let bytes = emission.bytes();
let mut start = 0;
emission
.bounds()
.iter()
.map(|&end| {
let unit = bytes[start..end].to_vec();
start = end;
unit
})
.collect()
}
fn feed(plugin: &mut dyn Plugin, input: &[u8]) -> Vec<Vec<u8>> {
let meta = meta();
let mut emission = Emission::new();
let mut sink = Silent;
{
let mut ctx = Ctx::new(&meta, BLOCK, input, &mut emission, &mut sink);
plugin.on_bytes(&mut ctx, input).expect("on_bytes");
}
assert_ne!(
emission.emit(),
Emit::Passthrough,
"block never passes bytes through",
);
units(&emission)
}
fn drive(plugin: &mut dyn Plugin, eof: bool) -> Vec<Vec<u8>> {
let meta = meta();
let mut emission = Emission::new();
let mut sink = Silent;
{
let mut ctx = Ctx::new(&meta, BLOCK, &[], &mut emission, &mut sink);
if eof {
plugin.on_eof(&mut ctx).expect("on_eof");
} else {
plugin.on_tick(&mut ctx).expect("on_tick");
}
}
units(&emission)
}
fn rearms(plugin: &mut dyn Plugin, input: &[u8]) -> bool {
let meta = meta();
let mut emission = Emission::new();
let mut sink = Silent;
{
let mut ctx = Ctx::new(&meta, BLOCK, input, &mut emission, &mut sink);
plugin.on_bytes(&mut ctx, input).expect("on_bytes");
}
emission.rearm_requested()
}
fn tick(plugin: &mut dyn Plugin) -> Vec<Vec<u8>> {
drive(plugin, false)
}
fn finish(plugin: &mut dyn Plugin) -> Vec<Vec<u8>> {
drive(plugin, true)
}
#[test]
fn nothing_is_emitted_until_a_block_is_full() {
let mut block = built(json!({"size": 4}));
assert!(feed(block.as_mut(), b"abc").is_empty());
assert_eq!(feed(block.as_mut(), b"d"), [b"abcd".to_vec()]);
}
#[test]
fn a_chunk_larger_than_a_block_becomes_several() {
let mut block = built(json!({"size": 2}));
assert_eq!(
feed(block.as_mut(), b"abcdef"),
[b"ab".to_vec(), b"cd".to_vec(), b"ef".to_vec()],
);
}
#[test]
fn no_bytes_are_lost_across_uneven_writes() {
let mut block = built(json!({"size": 4}));
let mut seen: Vec<Vec<u8>> = Vec::new();
for write in [&b"ab"[..], b"cdefghi", b"", b"jk", b"lmn"] {
seen.extend(feed(block.as_mut(), write));
}
seen.extend(finish(block.as_mut()));
assert_eq!(
seen,
[
b"abcd".to_vec(),
b"efgh".to_vec(),
b"ijkl".to_vec(),
b"mn".to_vec(),
],
);
}
#[test]
fn a_short_block_is_emitted_at_end_of_stream() {
let mut block = built(json!({"size": 8}));
assert!(feed(block.as_mut(), b"abc").is_empty());
assert_eq!(finish(block.as_mut()), [b"abc".to_vec()]);
}
#[test]
fn end_of_stream_on_an_empty_buffer_emits_nothing() {
let mut block = built(json!({"size": 8}));
assert!(finish(block.as_mut()).is_empty());
}
#[test]
fn a_short_block_is_padded_when_asked() {
let mut block = built(json!({"size": 8, "pad": true}));
assert!(feed(block.as_mut(), b"abc").is_empty());
assert_eq!(finish(block.as_mut()), [b"abc\0\0\0\0\0".to_vec()]);
}
#[test]
fn a_full_block_is_unaffected_by_padding() {
let mut block = built(json!({"size": 4, "pad": true}));
assert_eq!(feed(block.as_mut(), b"abcd"), [b"abcd".to_vec()]);
}
#[test]
fn a_tick_on_an_idle_stream_emits_nothing() {
let mut block = built(json!({"size": 8, "pad": true, "flush": "1s"}));
assert!(tick(block.as_mut()).is_empty());
assert!(tick(block.as_mut()).is_empty());
}
#[test]
fn a_tick_releases_a_partial_block() {
let mut block = built(json!({"size": 8, "flush": "1s"}));
assert!(feed(block.as_mut(), b"abc").is_empty());
assert_eq!(tick(block.as_mut()), [b"abc".to_vec()]);
assert!(tick(block.as_mut()).is_empty(), "and nothing twice");
}
#[test]
fn the_flush_clock_restarts_when_a_block_starts_filling() {
let mut block = built(json!({"size": 8, "flush": "1s"}));
assert!(rearms(block.as_mut(), b"abc"), "a fresh block starts it");
assert!(!rearms(block.as_mut(), b"de"), "adding to one does not");
assert!(
rearms(block.as_mut(), b"fghijk"),
"and filling one starts the clock for the next",
);
}
#[test]
fn a_flush_interval_does_not_hold_back_a_full_block() {
let mut block = built(json!({"size": 4, "flush": "1h"}));
assert_eq!(feed(block.as_mut(), b"abcd"), [b"abcd".to_vec()]);
}
#[test]
fn a_zero_flush_emits_on_every_write() {
let mut block = built(json!({"size": 4096, "flush": 0}));
assert_eq!(feed(block.as_mut(), b"ab"), [b"ab".to_vec()]);
assert_eq!(feed(block.as_mut(), b"cd"), [b"cd".to_vec()]);
}
#[test]
fn only_a_nonzero_flush_asks_for_a_timer() {
assert_eq!(built(json!({})).tick_interval(), None);
assert_eq!(built(json!({"flush": 0})).tick_interval(), None);
assert_eq!(
built(json!({"flush": "30s"})).tick_interval(),
Some(std::time::Duration::from_secs(30)),
);
}
#[test]
fn the_default_size_applies_when_none_is_given() {
let mut block = built(json!({}));
assert!(feed(block.as_mut(), &vec![0u8; DEFAULT_BYTESIZE - 1]).is_empty());
assert_eq!(
feed(block.as_mut(), b"!"),
[vec![0u8; DEFAULT_BYTESIZE - 1]
.into_iter()
.chain(*b"!")
.collect::<Vec<u8>>()]
);
}
#[test]
fn a_zero_size_is_rejected() {
assert!(build(json!({"size": 0})).is_err());
}
#[test]
fn block_fuses_the_boundaries_it_was_given() {
assert_eq!(built(json!({})).boundaries(), Boundaries::Fuse);
}
}