Compare commits

...

10 Commits

Author SHA1 Message Date
409
0b0449e995 README.md better line breaks 2025-06-17 23:28:51 +02:00
409
e05a9d3d11 update README.md 2025-06-17 23:28:19 +02:00
409
4d45b5c4bc update compose.yaml 2025-06-17 23:26:44 +02:00
409
3d2d9d30ee docker 2025-06-17 23:08:32 +02:00
409
4d4ccc1d14 cli 2025-06-17 22:31:34 +02:00
409
7c0acc3ecf refactor set command 2025-06-17 21:21:16 +02:00
409
2c0942fee1 also parse LOG_LEVEL env variable 2025-06-17 21:14:11 +02:00
409
0a9c8f81aa persist command 2025-06-17 21:11:09 +02:00
409
c51c90b597 expire command 2025-06-17 21:00:40 +02:00
409
8ac4dac2f0 add README.md 2025-06-17 02:17:13 +02:00
15 changed files with 505 additions and 40 deletions

54
Cargo.lock generated
View File

@@ -83,8 +83,10 @@ dependencies = [
"bon",
"bytes",
"byteyarn",
"clap",
"env_logger",
"log",
"shell-words",
"thiserror",
"tokio",
]
@@ -177,6 +179,46 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
[[package]]
name = "clap"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
[[package]]
name = "colorchoice"
version = "1.0.4"
@@ -253,6 +295,12 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -500,6 +548,12 @@ dependencies = [
"syn",
]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]]
name = "signal-hook-registry"
version = "1.4.5"

View File

@@ -2,12 +2,24 @@
name = "archive"
version = "0.1.0"
edition = "2024"
authors = ["409"]
default-run = "archive-server"
[dependencies]
bon = "3.6.4"
bytes = "1.10.1"
byteyarn = "0.5.1"
clap = { version = "4.5.40", features = ["derive"] }
env_logger = "0.11.8"
log = "0.4.27"
shell-words = "1.1.0"
thiserror = "2.0.12"
tokio = { version = "1.45.1", features = ["full"] }
[[bin]]
name = "archive-server"
path = "src/bin/server.rs"
[[bin]]
name = "archive-cli"
path = "src/bin/cli.rs"

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM rust:bullseye AS builder
WORKDIR /usr/src/archive
COPY Cargo.toml .
RUN mkdir -p src/bin && echo "fn main() {}" > src/bin/server.rs
RUN cargo build --release --bin archive-server
COPY src src
RUN touch src/main.rs
RUN cargo build --release --bin archive-server
RUN strip target/release/archive-server
FROM debian:12.11
RUN apt-get update
COPY --from=builder /usr/src/archive/target/release/archive-server /usr/local/bin/archive-server
ENTRYPOINT ["archive-server"]

34
README.md Normal file
View File

@@ -0,0 +1,34 @@
![Logo](logo.webp)
# ArcHIVE
A lightweight in-memory database written in Rust
## Usage
### CLI
Download a binary or compile it yourself using `cargo build --release --bin archive-cli`
To compile the CLI and install it globally run `cargo install --path .`
### Server
#### Docker
The repository contains a simple `compose.yaml`
#### Binary
Alternatively you can run the server binary directly
Download a binary or compile it yourself using `cargo build --release --bin archive-server`
#### Environment variables
This is a list of the server's environment variables and their default values:
- SERVER_HOST=0.0.0.0
- SERVER_PORT=6171
- MAX_CONNECTIONS=256
- LOG_LEVEL=info

12
compose.yaml Normal file
View File

@@ -0,0 +1,12 @@
services:
archive-server:
container_name: 'archive-server'
image: 'git.409dev.buzz/409/archive:latest'
build: '.'
ports:
- '6171:6171/tcp'
environment:
- 'SERVER_HOST=0.0.0.0'
- 'SERVER_PORT=6171'
- 'MAX_CONNECTIONS=256'
- 'LOG_LEVEL=info'

123
src/bin/cli.rs Normal file
View File

@@ -0,0 +1,123 @@
use std::io::{Write as _, stdin};
use archive::{Result, client::Client};
use clap::Parser;
#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
struct Args {
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(short, long, default_value_t = 6171)]
port: u16,
}
#[derive(Debug, Parser)]
#[command(help_template = "{subcommands}")]
enum Commands {
Get {
key: String,
},
Set {
key: String,
value: String,
expiration: Option<u64>,
},
Delete {
key: String,
},
Has {
key: String,
},
Ttl {
key: String,
},
Expire {
key: String,
seconds: u64,
},
Persist {
key: String,
},
#[command(aliases = &["exit", "q"])]
Quit,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
let mut client = Client::new(&format!("{}:{}", args.host, args.port)).await?;
print_welcome();
let stdin = stdin();
let mut input = String::new();
loop {
input.clear();
print!("> ");
std::io::stdout().flush()?;
stdin.read_line(&mut input)?;
let Ok(mut words) = shell_words::split(&input) else {
continue;
};
words.insert(0, "".into());
let command = match Commands::try_parse_from(words.clone()) {
Ok(command) => command,
Err(e) => {
println!("{}", e.render());
continue;
}
};
match command {
Commands::Get { key } => {
let value = client.get(&key).await?;
println!("{value:?}");
}
Commands::Set {
key,
value,
expiration,
} => {
client.set(&key, value.as_bytes(), expiration).await?;
println!("1");
}
Commands::Delete { key } => {
let value = client.delete(&key).await?;
println!("{value:?}");
}
Commands::Has { key } => {
let value = client.has(&key).await?;
println!("{value:?}");
}
Commands::Ttl { key } => {
let value = client.ttl(&key).await?;
println!("{value:?}");
}
Commands::Expire { key, seconds } => {
let value = client.expire(&key, seconds).await?;
println!("{value:?}");
}
Commands::Persist { key } => {
let value = client.persist(&key).await?;
println!("{value:?}");
}
Commands::Quit => break,
}
}
println!("{input}");
Ok(())
}
fn print_welcome() {
let version = env!("CARGO_PKG_VERSION");
println!("archive-cli {version}");
}

View File

@@ -1,27 +1,15 @@
use config::ServerConfig;
use errors::AppError;
use server::Server;
use archive::Result;
use archive::config::ServerConfig;
use archive::server::Server;
use tokio::{signal::ctrl_c, sync::oneshot};
#[cfg(test)]
pub mod tests;
pub mod client;
pub mod commands;
pub mod config;
pub mod connection;
pub mod database;
pub mod errors;
pub mod handler;
pub mod server;
pub type Result<T> = std::result::Result<T, AppError>;
#[tokio::main]
async fn main() -> Result<()> {
env_logger::builder()
.format_target(false)
.filter_level(log::LevelFilter::Info)
.parse_env("LOG_LEVEL")
.parse_default_env()
.init();

View File

@@ -170,6 +170,60 @@ impl Client {
Ok(ttl)
}
pub async fn expire(&mut self, key: &str, seconds: u64) -> Result<bool> {
let mut bytes = BytesMut::new();
bytes.put_u16(6);
bytes.put_slice(b"expire");
let key_length: u16 = key
.len()
.try_into()
.map_err(|_| AppError::KeyLength(key.len()))?;
bytes.put_u16(key_length);
bytes.put_slice(key.as_bytes());
bytes.put_u64(seconds);
self.connection.write(bytes.into()).await?;
let mut r = self.get_response().await?;
let success = match r.try_get_u8()? {
1 => true,
0 => false,
_ => return Err(AppError::InvalidCommandResponse),
};
Ok(success)
}
pub async fn persist(&mut self, key: &str) -> Result<bool> {
let mut bytes = BytesMut::new();
bytes.put_u16(7);
bytes.put_slice(b"persist");
let key_length: u16 = key
.len()
.try_into()
.map_err(|_| AppError::KeyLength(key.len()))?;
bytes.put_u16(key_length);
bytes.put_slice(key.as_bytes());
self.connection.write(bytes.into()).await?;
let mut r = self.get_response().await?;
let success = match r.try_get_u8()? {
1 => true,
0 => false,
_ => return Err(AppError::InvalidCommandResponse),
};
Ok(success)
}
async fn get_response(&mut self) -> Result<Bytes> {
self.connection
.read_bytes()

37
src/commands/expire.rs Normal file
View File

@@ -0,0 +1,37 @@
use std::io::Cursor;
use bytes::{Buf as _, Bytes};
use crate::{Result, connection::Connection, database::Database, errors::AppError};
#[derive(Debug, Clone)]
pub struct Expire {
key: String,
seconds: u64,
}
impl Expire {
pub async fn execute(self, db: &Database, connection: &mut Connection) -> Result<()> {
let value = db.expire(&self.key, self.seconds).await?;
connection
.write(Bytes::from_static(if value { &[1] } else { &[0] }))
.await?;
Ok(())
}
pub fn parse(bytes: &mut Cursor<&[u8]>) -> Result<Self> {
let key_length = bytes.try_get_u16()? as usize;
if bytes.remaining() < key_length {
return Err(AppError::IncompleteCommandBuffer);
}
let key = String::from_utf8(bytes.copy_to_bytes(key_length).to_vec())?;
let seconds = bytes.try_get_u64()?;
Ok(Self { key, seconds })
}
}

View File

@@ -1,6 +1,8 @@
mod delete;
mod expire;
mod get;
mod has;
mod persist;
mod set;
mod ttl;
@@ -8,8 +10,10 @@ use std::io::Cursor;
use bytes::{Buf, BytesMut};
use delete::Delete;
use expire::Expire;
use get::Get;
use has::Has;
use persist::Persist;
use set::Set;
use ttl::Ttl;
@@ -22,6 +26,8 @@ pub enum Command {
Delete(Delete),
Has(Has),
Ttl(Ttl),
Expire(Expire),
Persist(Persist),
}
impl Command {
@@ -32,6 +38,8 @@ impl Command {
Command::Delete(delete) => delete.execute(db, connection).await,
Command::Has(has) => has.execute(db, connection).await,
Command::Ttl(ttl) => ttl.execute(db, connection).await,
Command::Expire(expire) => expire.execute(db, connection).await,
Command::Persist(persist) => persist.execute(db, connection).await,
}
}
@@ -56,6 +64,8 @@ impl Command {
"delete" => Self::Delete(Delete::parse(bytes)?),
"has" => Self::Has(Has::parse(bytes)?),
"ttl" => Self::Ttl(Ttl::parse(bytes)?),
"expire" => Self::Expire(Expire::parse(bytes)?),
"persist" => Self::Persist(Persist::parse(bytes)?),
_ => return Err(AppError::UnknownCommand(command_name)),
};

34
src/commands/persist.rs Normal file
View File

@@ -0,0 +1,34 @@
use std::io::Cursor;
use bytes::{Buf as _, Bytes};
use crate::{Result, connection::Connection, database::Database, errors::AppError};
#[derive(Debug, Clone)]
pub struct Persist {
key: String,
}
impl Persist {
pub async fn execute(self, db: &Database, connection: &mut Connection) -> Result<()> {
let value = db.persist(&self.key).await?;
connection
.write(Bytes::from_static(if value { &[1] } else { &[0] }))
.await?;
Ok(())
}
pub fn parse(bytes: &mut Cursor<&[u8]>) -> Result<Self> {
let key_length = bytes.try_get_u16()? as usize;
if bytes.remaining() < key_length {
return Err(AppError::IncompleteCommandBuffer);
}
let key = String::from_utf8(bytes.copy_to_bytes(key_length).to_vec())?;
Ok(Self { key })
}
}

View File

@@ -1,24 +1,18 @@
use std::{io::Cursor, time::Duration};
use std::io::Cursor;
use crate::{Result, connection::Connection, database::Database, errors::AppError};
use bytes::{Buf as _, Bytes};
use tokio::time::Instant;
use crate::{
Result,
connection::Connection,
database::{Database, Value},
errors::AppError,
};
#[derive(Debug, Clone)]
pub struct Set {
key: String,
value: Value,
data: Box<[u8]>,
expiration: Option<u64>,
}
impl Set {
pub async fn execute(self, db: &Database, connection: &mut Connection) -> Result<()> {
db.set(self.key, self.value).await?;
db.set(self.key, self.data, self.expiration).await?;
connection.write(Bytes::from_static(&[1])).await?;
@@ -42,15 +36,16 @@ impl Set {
let data = bytes.copy_to_bytes(value_length);
let expiration: Option<Instant> = match bytes.try_get_u8()? {
1 => Some(Instant::now() + Duration::from_secs(bytes.try_get_u64()?)),
let expiration: Option<u64> = match bytes.try_get_u8()? {
1 => Some(bytes.try_get_u64()?),
0 => None,
_ => return Err(AppError::UnexpectedCommandData),
};
Ok(Self {
key,
value: Value::new(data, expiration),
data: (*data).into(),
expiration,
})
}
}

View File

@@ -1,9 +1,10 @@
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
time::Duration,
};
use bytes::{BufMut, Bytes, BytesMut};
use bytes::{BufMut, BytesMut};
use byteyarn::Yarn;
use tokio::{
sync::{Mutex, Notify},
@@ -32,11 +33,8 @@ pub struct Value {
}
impl Value {
pub fn new(data: Bytes, expiration: Option<Instant>) -> Self {
Self {
data: data.into_iter().collect(),
expiration,
}
pub fn new(data: Box<[u8]>, expiration: Option<Instant>) -> Self {
Self { data, expiration }
}
pub fn from_string(data: String, expiration: Option<Instant>) -> Self {
@@ -68,13 +66,15 @@ impl Database {
state.entries.get(key).map(|v| v.data.clone())
}
pub async fn set(&self, key: String, value: Value) -> Result<()> {
pub async fn set(&self, key: String, data: Box<[u8]>, expiration: Option<u64>) -> Result<()> {
let mut state = self.state.lock().await;
let expiration = value.expiration.clone();
let expiration = expiration.map(|seconds| Instant::now() + Duration::from_secs(seconds));
let key = Yarn::from(key.clone());
let value = Value::new(data, expiration);
let previous = state.entries.insert(key.clone(), value);
let mut notify = false;
@@ -138,6 +138,73 @@ impl Database {
.flatten()
}
pub async fn expire(&self, key: &str, seconds: u64) -> Result<bool> {
let mut state = self.state.lock().await;
let key = Yarn::copy(key);
let expiration = Instant::now() + Duration::from_secs(seconds);
let notify =
state
.expirations
.iter()
.next()
.is_none_or(|&(instant, ref next_expiration_key)| {
next_expiration_key == &key || instant > expiration
});
let Some(value) = state.entries.get_mut(&key) else {
return Ok(false);
};
let previous_expiration = value.expiration.take();
value.expiration = Some(expiration);
if let Some(previous_expiration) = previous_expiration {
state
.expirations
.remove(&(previous_expiration, key.clone()));
};
state.expirations.insert((expiration, key));
if notify {
self.notify.notify_one();
}
Ok(true)
}
pub async fn persist(&self, key: &str) -> Result<bool> {
let mut state = self.state.lock().await;
let key = Yarn::copy(key);
let notify = state
.expirations
.iter()
.next()
.is_some_and(|&(_, ref next_expiration_key)| next_expiration_key == &key);
let Some(value) = state.entries.get_mut(&key) else {
return Ok(false);
};
match value.expiration.take() {
Some(expiration) => {
state.expirations.remove(&(expiration, key));
}
None => return Ok(false),
}
if notify {
self.notify.notify_one();
}
Ok(true)
}
pub async fn shutdown(&mut self) {
self.state.lock().await.shutdown = true;
self.notify.notify_one();

15
src/lib.rs Normal file
View File

@@ -0,0 +1,15 @@
use errors::AppError;
#[cfg(test)]
pub mod tests;
pub mod client;
pub mod commands;
pub mod config;
pub mod connection;
pub mod database;
pub mod errors;
pub mod handler;
pub mod server;
pub type Result<T> = std::result::Result<T, AppError>;

View File

@@ -15,7 +15,7 @@ async fn expiration() -> Result<(), Box<dyn std::error::Error>> {
let mut client = client("127.0.0.1:6171").await?;
client
.set("test-key", "test-value".as_bytes(), Some(3))
.set("test-key", b"test-value", Some(3))
.await
.unwrap();
@@ -27,9 +27,22 @@ async fn expiration() -> Result<(), Box<dyn std::error::Error>> {
assert!(client.has("test-key").await.unwrap());
assert_eq!(client.ttl("test-key").await.unwrap(), Some(1));
assert!(client.expire("test-key", 2).await?);
tokio::time::sleep(Duration::from_secs(2)).await;
assert!(!client.has("test-key").await.unwrap());
assert!(!client.expire("test-key", 10).await?);
client.set("test-key", b"test-value", Some(2)).await?;
assert_eq!(client.ttl("test-key").await?, Some(1));
assert!(client.persist("test-key").await?);
tokio::time::sleep(Duration::from_secs(2)).await;
assert_eq!(client.get("test-key").await?, Some("test-value".into()));
shutdown_tx.send(()).unwrap();
server_handle.await??;