Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/*"]

[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://github.com/thepartly/partly-proxy"
Expand All @@ -17,9 +17,9 @@ rust-version = "1.85"
# - `cargo publish` / `cargo release` use `version` from the registry.
# Keep these versions in lock-step with `workspace.package.version` above;
# `release.toml` (shared-version = true) enforces that on release.
partly-proxy-types = { version = "0.4.0", path = "crates/partly-proxy-types" }
partly-proxy-storage-jsonl = { version = "0.4.0", path = "crates/partly-proxy-storage-jsonl" }
partly-proxy-storage-sqlite = { version = "0.4.0", path = "crates/partly-proxy-storage-sqlite" }
partly-proxy-types = { version = "0.5.0", path = "crates/partly-proxy-types" }
partly-proxy-storage-jsonl = { version = "0.5.0", path = "crates/partly-proxy-storage-jsonl" }
partly-proxy-storage-sqlite = { version = "0.5.0", path = "crates/partly-proxy-storage-sqlite" }
# `partly-proxy-echo` is `publish = false`; only consumed as a dev-dep
# inside the workspace. Path-only is fine since dev-deps without a
# version are stripped from the published manifest.
Expand Down
69 changes: 40 additions & 29 deletions SPECIFICATION.md

Large diffs are not rendered by default.

6 changes: 2 additions & 4 deletions crates/partly-proxy-lib/benches/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ use hyper_util::{
};
use partly_proxy_echo as echo;
use partly_proxy_lib::{
ClusterHandle, MatchStrategy, ProxyClusterBuilder, ProxyConfig, RecordingConfig, SharedStorage,
Snapshots, UpstreamTarget,
ClusterHandle, ProxyClusterBuilder, ProxyConfig, RecordingConfig, SharedStorage, UpstreamTarget,
};
use tempfile::TempDir;
use tokio::task::JoinHandle;
Expand Down Expand Up @@ -135,8 +134,7 @@ pub async fn spawn_proxy(recording: Recording) -> ProxyHandle {
max_in_memory: 10_000,
};

let snapshots = storage
.map(|storage| Snapshots::from_storage(storage, MatchStrategy::MethodUriAndBodyHash));
let snapshots = storage;
let builder = ProxyClusterBuilder::new().recording(cfg).add_upstream_with(
"upstream",
ProxyConfig::http(
Expand Down
10 changes: 3 additions & 7 deletions crates/partly-proxy-lib/examples/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@
use std::{net::SocketAddr, sync::Arc};

use partly_proxy_lib::{
MatchStrategy, ProxyClusterBuilder, ProxyConfig, RecordingConfig, Result, SharedStorage,
Snapshots, UpstreamTarget,
ProxyClusterBuilder, ProxyConfig, RecordingConfig, Result, SharedStorage, UpstreamTarget,
};

#[tokio::main]
Expand Down Expand Up @@ -51,14 +50,11 @@ async fn main() -> Result<()> {
// Storage is configured per upstream: the same medium is loaded for
// replay and appended to while recording. Here the single "upstream"
// gets its own JSONL file when PARTLY_PROXY_RECORDING_PATH is set.
let snapshots: Option<Snapshots> = match std::env::var("PARTLY_PROXY_RECORDING_PATH").ok() {
let snapshots: Option<SharedStorage> = match std::env::var("PARTLY_PROXY_RECORDING_PATH").ok() {
Some(path) => {
let storage: SharedStorage =
Arc::new(partly_proxy_lib::jsonl::JsonlStorage::open(path).await?);
Some(Snapshots::from_storage(
storage,
MatchStrategy::MethodUriAndBodyHash,
))
Some(storage)
}
None => None,
};
Expand Down
58 changes: 30 additions & 28 deletions crates/partly-proxy-lib/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use crate::{
middleware::{ProxyMiddleware, SharedMiddleware},
proxy_io::{ProxyRequest, ProxyResponse},
recorder::Recorder,
replay::Snapshots,
replay::ReplaySource,
upstream::UpstreamRegistry,
};

Expand Down Expand Up @@ -85,9 +85,9 @@ pub(crate) struct UpstreamSpec {
pub name: String,
pub config: ProxyConfig,
pub middleware: Vec<SharedMiddleware>,
/// Per-upstream snapshot medium — loaded for replay and (in `Record`)
/// Per-upstream storage backend — loaded for replay and (in `Record`)
/// appended to as the recording sink. Resolved at `run()`.
pub snapshots: Option<Snapshots>,
pub storage: Option<SharedStorage>,
pub mode: Mode,
pub replay_miss_handler: ReplayMissHandler,
}
Expand All @@ -97,7 +97,7 @@ impl std::fmt::Debug for UpstreamSpec {
f.debug_struct("UpstreamSpec")
.field("name", &self.name)
.field("middleware", &self.middleware.len())
.field("snapshots", &self.snapshots.is_some())
.field("storage", &self.storage.is_some())
.field("mode", &self.mode)
.finish_non_exhaustive()
}
Expand Down Expand Up @@ -152,7 +152,7 @@ impl ProxyClusterBuilder {
name: name.into(),
config,
middleware: Vec::new(),
snapshots: None,
storage: None,
mode: self.default_mode,
replay_miss_handler: Arc::clone(&self.replay_miss_handler),
});
Expand All @@ -172,22 +172,25 @@ impl ProxyClusterBuilder {
name: name.into(),
config,
middleware,
snapshots: None,
storage: None,
mode: self.default_mode,
replay_miss_handler: Arc::clone(&self.replay_miss_handler),
});
self
}

/// Register an upstream with both per-upstream middleware and an
/// optional [`Snapshots`] medium. Uses the builder's current
/// [`default_mode`](Self::default_mode).
/// optional [`SnapshotStorage`](crate::SnapshotStorage) backend. Uses the
/// builder's current [`default_mode`](Self::default_mode).
///
/// The `snapshots` medium is the single per-upstream storage knob: at
/// The `storage` backend is the single per-upstream storage knob: at
/// [`run()`](Self::run) its existing contents are loaded into the replay
/// source, and in [`Mode::Record`] every new exchange for this upstream
/// is appended back to it. Give each upstream its own medium (e.g. its
/// own JSONL file) to keep recordings separate.
/// is appended back to it. Construct a backend
/// (e.g. `JsonlStorage::open(path)` or
/// [`InMemoryStorage`](crate::InMemoryStorage)), wrap it in an `Arc`, and
/// pass it here. Give each upstream its own backend to keep recordings
/// separate.
///
/// See `SPECIFICATION.md` §8.3: in `Record` mode, stubs take priority
/// over replay, which takes priority over the upstream forward. To
Expand All @@ -198,13 +201,13 @@ impl ProxyClusterBuilder {
name: impl Into<String>,
config: ProxyConfig,
middleware: Vec<SharedMiddleware>,
snapshots: Option<Snapshots>,
storage: Option<SharedStorage>,
) -> Self {
self.upstreams.push(UpstreamSpec {
name: name.into(),
config,
middleware,
snapshots,
storage,
mode: self.default_mode,
replay_miss_handler: Arc::clone(&self.replay_miss_handler),
});
Expand All @@ -218,20 +221,20 @@ impl ProxyClusterBuilder {
/// missing snapshot yields the replay-miss response (default `503 {}`).
/// In [`Mode::Record`] the terminal falls through to the upstream on
/// miss and (when recording is enabled) appends the exchange to the
/// upstream's [`Snapshots`] medium.
/// upstream's `storage` backend.
pub fn add_upstream_with_mode(
mut self,
name: impl Into<String>,
config: ProxyConfig,
middleware: Vec<SharedMiddleware>,
snapshots: Option<Snapshots>,
storage: Option<SharedStorage>,
mode: Mode,
) -> Self {
self.upstreams.push(UpstreamSpec {
name: name.into(),
config,
middleware,
snapshots,
storage,
mode,
replay_miss_handler: Arc::clone(&self.replay_miss_handler),
});
Expand All @@ -256,7 +259,7 @@ impl ProxyClusterBuilder {
name: name.into(),
config,
middleware,
snapshots: None,
storage: None,
mode: Mode::Replay,
replay_miss_handler: Arc::clone(&self.replay_miss_handler),
});
Expand Down Expand Up @@ -321,20 +324,19 @@ impl ProxyClusterBuilder {
}
}

// Resolve each upstream's snapshot medium up front: load its
// contents into a replay source for the hot path, and collect the
// durable media into a per-upstream routing map for the recorder.
// Loading is async (it streams the backend), so it happens here in
// `run()` rather than in the synchronous `add_upstream_*` builders.
// Resolve each upstream's storage backend up front: load its
// contents into a replay source for the hot path, and register the
// backend in a per-upstream routing map so the recorder appends new
// exchanges back to it. Loading is async (it streams the backend),
// so it happens here in `run()` rather than in the synchronous
// `add_upstream_*` builders.
let mut routes: HashMap<String, SharedStorage> = HashMap::new();
let mut resolved = Vec::with_capacity(self.upstreams.len());
for mut spec in self.upstreams {
let replay = match spec.snapshots.take() {
Some(snapshots) => {
let (replay, storage) = snapshots.resolve().await?;
if let Some(storage) = storage {
routes.insert(spec.name.clone(), storage);
}
let replay = match spec.storage.take() {
Some(storage) => {
let replay = ReplaySource::from_storage(storage.as_ref()).await?;
routes.insert(spec.name.clone(), storage);
Some(replay)
}
None => None,
Expand Down
5 changes: 2 additions & 3 deletions crates/partly-proxy-lib/src/cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,8 @@ impl ClusterHandle {
}

/// Shared recorder — cheap to clone. Holds the cluster-wide in-memory
/// ring and routes each exchange to its upstream's durable medium (if
/// one was attached via a [`Snapshots`](crate::Snapshots)). See
/// `SPECIFICATION.md` §9.
/// ring and routes each exchange to its upstream's durable storage
/// backend (if one was attached). See `SPECIFICATION.md` §9.
pub fn recorder(&self) -> &Recorder {
&self.recorder
}
Expand Down
8 changes: 4 additions & 4 deletions crates/partly-proxy-lib/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ impl Default for UpstreamTarget {
/// Controls the recorder's in-memory ring buffer only. Persistence —
/// NDJSON file, `SQLite` database, or anything else implementing
/// [`SnapshotStorage`](crate::SnapshotStorage) — is configured per
/// upstream by attaching a [`Snapshots`](crate::Snapshots) medium via
/// upstream by attaching a storage backend via
/// [`ProxyClusterBuilder::add_upstream_with`](crate::ProxyClusterBuilder::add_upstream_with).
#[derive(Debug, Clone)]
pub struct RecordingConfig {
Expand Down Expand Up @@ -139,9 +139,9 @@ impl RecordingConfig {
/// and no replay hit:
///
/// - [`Mode::Record`] forwards to the upstream and records the exchange.
/// When a [`ReplaySource`](crate::ReplaySource) is also configured, replay
/// hits short-circuit before the forward (so previously-seen requests
/// don't re-hit the upstream).
/// When a storage backend is also attached, replay hits short-circuit
/// before the forward (so previously-seen requests don't re-hit the
/// upstream).
/// - [`Mode::Replay`] never touches the upstream. A miss yields a `503` with
/// an empty-JSON-object body (`{}`).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
Expand Down
5 changes: 2 additions & 3 deletions crates/partly-proxy-lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,10 @@ pub use partly_proxy_storage_jsonl as jsonl;
#[cfg(feature = "storage-sqlite")]
pub use partly_proxy_storage_sqlite as sqlite;
pub use partly_proxy_types::{
ExchangeOutcome, ProxyError, RecordedExchange, RecordedRequest, RecordedResponse, Result,
SharedStorage, SnapshotStorage,
ExchangeOutcome, InMemoryStorage, ProxyError, RecordedExchange, RecordedRequest,
RecordedResponse, Result, SharedStorage, SnapshotStorage,
};
pub use proxy_io::{ProxyRequest, ProxyResponse};
pub use recorder::Recorder;
pub use replay::{MatchStrategy, ReplaySource, Snapshots};
pub use stub::{RequestMatcher, StubEntry, StubStore, StubbedResponse};
pub use wire::{StubFields, WireCommand, WireFilter, WireResponse};
2 changes: 1 addition & 1 deletion crates/partly-proxy-lib/src/recorder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ impl Recorder {
/// Build an in-memory-only recorder with no durable media. Persistence
/// — NDJSON, `SQLite`, object store, or anything else implementing
/// [`SnapshotStorage`](crate::SnapshotStorage) — is configured per
/// upstream by attaching a [`Snapshots`](crate::Snapshots) medium via
/// upstream by attaching a storage backend via
/// [`add_upstream_with`](crate::ProxyClusterBuilder::add_upstream_with);
/// the builder threads the resulting routes through
/// [`Recorder::with_routes`].
Expand Down
Loading
Loading