Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33f522d7e5 | ||
|
|
901fc51f7b | ||
|
|
d6534e80d8 | ||
|
|
1dfc6d6865 | ||
|
|
68a0ec9613 | ||
|
|
e14894479d | ||
|
|
f86abc4ce1 | ||
|
|
d00881c2b9 | ||
|
|
829c5714d2 | ||
|
|
5df04d7512 | ||
|
|
a6cf58ea7f | ||
|
|
de54e801fb | ||
|
|
d03d32a923 | ||
|
|
c87ae760bf | ||
|
|
e69f72296c | ||
|
|
730245e9c0 | ||
|
|
3376bee1d3 | ||
|
|
bb4998d8fa | ||
|
|
125bc57290 | ||
|
|
ff5fbe49dc | ||
|
|
8d4d8aa52c | ||
|
|
ddb2c2fd2d | ||
|
|
77b0dd9f12 | ||
|
|
74abe7172f | ||
|
|
3c64563a44 | ||
|
|
2f445d3b85 | ||
|
|
2985fa0a0e | ||
|
|
7a063fa75c | ||
|
|
ebb3c8ad6b | ||
|
|
076b0bb757 | ||
|
|
8e66c7d887 | ||
|
|
851c8c4ebb | ||
|
|
43063c4698 | ||
|
|
a03db38c5c | ||
|
|
f9df8f2887 | ||
|
|
97b5e02820 | ||
|
|
bf8f76cc10 | ||
|
|
510aadc066 | ||
|
|
0a978c063e | ||
|
|
717e6905cd | ||
|
|
bfe2fae359 | ||
|
|
1ea3b35581 | ||
|
|
d27e00060c | ||
|
|
198154442c | ||
|
|
bad65135aa | ||
|
|
8106f8943e | ||
|
|
2596b357ec | ||
|
|
2c9367a83c | ||
|
|
8ff33817a8 | ||
|
|
b6f138b90d | ||
|
|
4027d3eb17 | ||
|
|
5f1e3ace5c | ||
|
|
0f2b776bb8 | ||
|
|
5717f8aaaa | ||
|
|
52d9e1e93e | ||
|
|
19941156b4 | ||
|
|
b2d3f32d83 | ||
|
|
4d851c5a58 | ||
|
|
db4f277336 | ||
|
|
6a4b2b8f54 | ||
|
|
d119821339 | ||
|
|
ca39dea5db | ||
|
|
8dec413005 | ||
|
|
4043a681db | ||
|
|
7cdf52544c | ||
|
|
fa14529aa2 | ||
|
|
4b6e9f35ef | ||
|
|
5684938b4c | ||
|
|
c830d2b949 | ||
|
|
d3c35eccd0 | ||
|
|
e7a932f716 | ||
|
|
3c04aa44b6 | ||
|
|
5a2bb634a1 | ||
|
|
6c8c98e86c |
+60
-18
@@ -28,10 +28,10 @@ jobs:
|
|||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
- name: Clippy (all features)
|
- name: Clippy (workspace, all features)
|
||||||
run: cargo clippy --all-features -- -D warnings
|
run: cargo clippy --workspace --all-features -- -D warnings
|
||||||
- name: Clippy (no default features)
|
- name: Clippy (workspace, no default features)
|
||||||
run: cargo clippy --no-default-features -- -D warnings
|
run: cargo clippy --workspace --no-default-features -- -D warnings
|
||||||
|
|
||||||
test-matrix:
|
test-matrix:
|
||||||
name: Test (${{ matrix.name }})
|
name: Test (${{ matrix.name }})
|
||||||
@@ -40,16 +40,53 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: default features
|
# Whole-workspace sanity passes.
|
||||||
flags: ""
|
- name: workspace default features
|
||||||
- name: all features
|
flags: "--workspace"
|
||||||
flags: "--all-features"
|
- name: workspace all features
|
||||||
- name: io only
|
flags: "--workspace --all-features"
|
||||||
flags: "--no-default-features --features io"
|
# mytheclipse: execution primitives + resiliency/traffic/lifecycle/observability
|
||||||
- name: compute only
|
- name: mytheclipse / io only
|
||||||
flags: "--no-default-features --features compute"
|
flags: "-p mytheclipse --no-default-features --features io"
|
||||||
- name: bg only
|
- name: mytheclipse / compute only
|
||||||
flags: "--no-default-features --features bg"
|
flags: "-p mytheclipse --no-default-features --features compute"
|
||||||
|
- name: mytheclipse / bg only
|
||||||
|
flags: "-p mytheclipse --no-default-features --features bg"
|
||||||
|
- name: mytheclipse / resiliency only
|
||||||
|
flags: "-p mytheclipse --no-default-features --features resiliency"
|
||||||
|
- name: mytheclipse / traffic only
|
||||||
|
flags: "-p mytheclipse --no-default-features --features traffic"
|
||||||
|
- name: mytheclipse / lifecycle only
|
||||||
|
flags: "-p mytheclipse --no-default-features --features lifecycle"
|
||||||
|
- name: mytheclipse / observability only
|
||||||
|
flags: "-p mytheclipse --no-default-features --features observability"
|
||||||
|
# mytheclipse-cache
|
||||||
|
- name: mytheclipse-cache / default
|
||||||
|
flags: "-p mytheclipse-cache"
|
||||||
|
- name: mytheclipse-cache / l1-moka
|
||||||
|
flags: "-p mytheclipse-cache --no-default-features --features l1-moka"
|
||||||
|
- name: mytheclipse-cache / l2-redis
|
||||||
|
flags: "-p mytheclipse-cache --no-default-features --features l1-memory,l2-redis"
|
||||||
|
# mytheclipse-storage
|
||||||
|
- name: mytheclipse-storage / default (local)
|
||||||
|
flags: "-p mytheclipse-storage"
|
||||||
|
- name: mytheclipse-storage / s3
|
||||||
|
flags: "-p mytheclipse-storage --no-default-features --features s3"
|
||||||
|
- name: mytheclipse-storage / gcs
|
||||||
|
flags: "-p mytheclipse-storage --no-default-features --features gcs"
|
||||||
|
# mytheclipse-event
|
||||||
|
- name: mytheclipse-event / default (mem)
|
||||||
|
flags: "-p mytheclipse-event"
|
||||||
|
- name: mytheclipse-event / amqp
|
||||||
|
flags: "-p mytheclipse-event --no-default-features --features amqp"
|
||||||
|
- name: mytheclipse-event / nats
|
||||||
|
flags: "-p mytheclipse-event --no-default-features --features nats"
|
||||||
|
# mytheclipse-config
|
||||||
|
- name: mytheclipse-config / default
|
||||||
|
flags: "-p mytheclipse-config"
|
||||||
|
# mytheclipse-crypto
|
||||||
|
- name: mytheclipse-crypto / default
|
||||||
|
flags: "-p mytheclipse-crypto"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -59,12 +96,12 @@ jobs:
|
|||||||
run: cargo test ${{ matrix.flags }}
|
run: cargo test ${{ matrix.flags }}
|
||||||
|
|
||||||
example:
|
example:
|
||||||
name: Run example
|
name: Run mytheclipse example
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- run: cargo run --example main --features full
|
- run: cargo run -p mytheclipse --example main --features full
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
name: Docs check
|
name: Docs check
|
||||||
@@ -72,13 +109,18 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- run: RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps
|
- run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps
|
||||||
|
|
||||||
package:
|
package:
|
||||||
name: Cargo package dry-run
|
name: Cargo package dry-run
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
crate:
|
||||||
|
[mytheclipse, mytheclipse-cache, mytheclipse-storage, mytheclipse-event, mytheclipse-config, mytheclipse-crypto]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- name: Package
|
- name: Package
|
||||||
run: cargo package --no-verify
|
run: cargo package -p ${{ matrix.crate }} --no-verify
|
||||||
|
|||||||
@@ -25,7 +25,15 @@ jobs:
|
|||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Publish mytheclipse
|
# The workspace crates have no interdependencies, so publish order
|
||||||
run: cargo publish --allow-dirty --no-verify
|
# doesn't matter for crates.io dependency resolution. A brief sleep
|
||||||
|
# between publishes avoids hitting crates.io's rate limit.
|
||||||
|
- name: Publish workspace crates
|
||||||
|
run: |
|
||||||
|
for crate in mytheclipse mytheclipse-cache mytheclipse-storage mytheclipse-event mytheclipse-config mytheclipse-crypto; do
|
||||||
|
echo "Publishing $crate..."
|
||||||
|
cargo publish -p "$crate" --allow-dirty --no-verify
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
env:
|
env:
|
||||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||||
@@ -9,11 +9,14 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
name: Semantic Release
|
name: Semantic Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -40,14 +43,4 @@ jobs:
|
|||||||
id: semantic-release
|
id: semantic-release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: npx semantic-release
|
run: npx semantic-release
|
||||||
|
|
||||||
- name: Trigger crate publish
|
|
||||||
if: ${{ steps.semantic-release.outputs.new_release_published == 'true' }}
|
|
||||||
env:
|
|
||||||
RELEASE_VERSION: ${{ steps.semantic-release.outputs.next_release_version }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
TAG="v${RELEASE_VERSION}"
|
|
||||||
echo "Tagging and dispatching publish for $TAG"
|
|
||||||
gh workflow run publish.yml --ref main -f tag="$TAG"
|
|
||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
# Cargo build artifacts
|
# Cargo build artifacts (workspace root and any nested crate target dirs)
|
||||||
/target
|
/target
|
||||||
|
target/
|
||||||
|
|
||||||
# Editor / OS noise
|
# Editor / OS noise
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Implementation Spec: New Features for mytheclipse
|
||||||
|
|
||||||
|
## Status: COMPLETE
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Added 4 new crates and enhancements to existing crates to expand mytheclipse's
|
||||||
|
abstraction layer coverage. All code compiles with `cargo build --workspace --all-features`,
|
||||||
|
all tests pass, and clippy is clean.
|
||||||
|
|
||||||
|
## New Crates
|
||||||
|
|
||||||
|
1. **mytheclipse-queue** (`crates/mytheclipse-queue/`)
|
||||||
|
- `Queue` trait: enqueue, dequeue, ack, nack, dlq_move, len
|
||||||
|
- `Job` / `JobId` types with payload + metadata
|
||||||
|
- `WorkerPool` with configurable concurrency, retry/backoff, dead-letter queue
|
||||||
|
- `JobHandler` trait for processing jobs
|
||||||
|
- Backend: in-memory (default), Redis (feature `redis`), NATS (feature `nats`), PostgreSQL (feature `postgres`)
|
||||||
|
|
||||||
|
2. **mytheclipse-tracing** (`crates/mytheclipse-tracing/`)
|
||||||
|
- `TracingLayer` with env-filter support and subscriber builder
|
||||||
|
- `OtelLayer` for OTLP/Jaeger export (feature `otel`, `jaeger`, `full`)
|
||||||
|
- Features: `env` (default), `otel`, `jaeger`, `full`
|
||||||
|
|
||||||
|
3. **mytheclipse-http** (`crates/mytheclipse-http/`)
|
||||||
|
- `HttpClient` wrapping reqwest with timeout + tracing instrumentation
|
||||||
|
- `HttpServer` (axum) with health endpoint + graceful shutdown
|
||||||
|
- Features: `client` (default), `server-axum`, `server-hyper`
|
||||||
|
|
||||||
|
4. **mytheclipse-cli** (`crates/mytheclipse-cli/`)
|
||||||
|
- `CliApp` / `CliBuilder` with clap derive
|
||||||
|
- Subcommands: `serve`, `worker`, `migrate`, `health`, `version`
|
||||||
|
- Feature: `clap-derive` (default)
|
||||||
|
|
||||||
|
## Enhancements to Existing Crates
|
||||||
|
|
||||||
|
### mytheclipse (core)
|
||||||
|
- `pool.rs`: `SemaphorePool<T>` with `Pool` trait, `Pooled<T>` RAII permit
|
||||||
|
- `health.rs`: `HealthRegistry`, `HealthCheck` trait, `HealthStatus` enum
|
||||||
|
- `leader.rs`: `LeaderElection` trait, `InProcLeaderElection` impl
|
||||||
|
- Features: gated under `traffic` (pool) and `lifecycle` (health, leader)
|
||||||
|
|
||||||
|
### mytheclipse-cache
|
||||||
|
- `auto_refresh.rs`: `AutoRefreshCache` — background refresh on cache miss
|
||||||
|
- `metrics.rs`: `CacheMetrics` + `CacheSnapshot` with hit/miss/eviction tracking
|
||||||
|
- Added `tokio` optional dep (used by cache-aside + auto-refresh)
|
||||||
|
|
||||||
|
### mytheclipse-config
|
||||||
|
- `schema.rs`: `ConfigSchema` + `PropertySchema` for JSON Schema generation
|
||||||
|
- Feature `schema` gated
|
||||||
|
|
||||||
|
### mytheclipse-storage
|
||||||
|
- `multipart.rs`: `MultipartUploadDriver` trait + `MultipartUpload` handler
|
||||||
|
- Feature `multipart` (default) gated
|
||||||
|
|
||||||
|
### mytheclipse-crypto
|
||||||
|
- `paseto.rs`: `PasetoSigner` + `PasetoClaims` for PASETO v4.local tokens
|
||||||
|
- Features `paseto` and `rate-limit` added
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `cargo build --workspace --all-features` ✓
|
||||||
|
- `cargo test --workspace --all-features` ✓ (all pass, 1 ignored doctest)
|
||||||
|
- `cargo clippy --workspace --all-features` ✓ (no warnings)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Implementation Spec: Round 10 — COMPLETE
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Auto-integration + ergonomics: rate-limit workers, auto-metrics on service calls —
|
||||||
|
reduce manual wiring/boilerplate.
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### 1. AutoMetricsServiceBuilder (mytheclipse-core, observability)
|
||||||
|
File: `crates/mytheclipse/src/auto_metrics_service.rs`
|
||||||
|
- Composes ServiceBuilder + MetricsCollector (+ MetricsBridge when resiliency)
|
||||||
|
- `.run()` auto-records: calls_total counter (labelled by outcome ok/err/timeout/
|
||||||
|
circuit_open/rate_limited) + duration histogram; emits bridge when attached
|
||||||
|
- Chainable .with_collector/.with_bridge/.with_builders
|
||||||
|
- 1 test
|
||||||
|
|
||||||
|
### 2. RateLimitedWorkerPool (mytheclipse-queue, in-memory)
|
||||||
|
File: `crates/mytheclipse-queue/src/worker_rate_limited.rs`
|
||||||
|
- Wraps WorkerPool with RateLimitedQueue — token-bucket back-pressured dequeue,
|
||||||
|
prevents workers hammering upstream beyond rate limit
|
||||||
|
- new(queue, worker_cfg, rate_per_sec, burst) + start(topic, handler)
|
||||||
|
- 1 test (construction)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- new: core/src/auto_metrics_service.rs, queue/src/worker_rate_limited.rs
|
||||||
|
- core/lib.rs: +module+export AutoMetricsServiceBuilder
|
||||||
|
- queue/lib.rs: +module+export RateLimitedWorkerPool (rewrote export block)
|
||||||
|
|
||||||
|
Build: exit 0. Tests: 0 FAILED (86 core pass). Clippy: 0 new warnings.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Implementation Spec: Round 11 — COMPLETE
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Auto thread/core allocation + race hardening (RAII shutdown).
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### 1. RuntimeConfig (mytheclipse-core, lifecycle)
|
||||||
|
File: `crates/mytheclipse/src/runtime_auto.rs`
|
||||||
|
- `RuntimeConfig::auto()` / `from_cores(n)` / `compact()` infer worker_threads,
|
||||||
|
max_blocking_threads, compute_threads, io_threads from host CPU topology
|
||||||
|
(std::thread::available_parallelism)
|
||||||
|
- `available_parallelism()` helper
|
||||||
|
- `build_rayon_pool(cfg)` gated on `compute` feature
|
||||||
|
- 3 tests
|
||||||
|
|
||||||
|
### 2. ShutdownGuard (mytheclipse-core, lifecycle)
|
||||||
|
File: `crates/mytheclipse/src/shutdown_guard.rs`
|
||||||
|
- RAII guard — runs completion callback exactly once on drop (panic-safe via
|
||||||
|
Mutex<Option<Box<FnOnce>>>), prevents double-shutdown race
|
||||||
|
- `new(cb)` + `finish()` (fire now + disarm)
|
||||||
|
- 3 tests (fires on drop, finish once, panic path)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- new: core/src/runtime_auto.rs, core/src/shutdown_guard.rs
|
||||||
|
- core/lib.rs: +module+export for both
|
||||||
|
|
||||||
|
Build: exit 0. Tests: 0 FAILED. Clippy: 0 new warnings.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Implementation Spec: Round 12 — COMPLETE
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Self-healing resource pool (auto-reconnect) — remove per-call "is connection
|
||||||
|
dead? rebuild" boilerplate.
|
||||||
|
|
||||||
|
## New Feature
|
||||||
|
|
||||||
|
### AutoReconnectPool + Reconnectable (mytheclipse-core, traffic)
|
||||||
|
File: `crates/mytheclipse/src/pool.rs`
|
||||||
|
- `Reconnectable` trait: is_healthy(&item) sync probe + reconnect() async builder
|
||||||
|
- `AutoReconnectPool<P,R>` wraps any Pool<T>; on acquire, checks checked-out item
|
||||||
|
health and transparently replaces dead ones via reconnect() — reuses the
|
||||||
|
permit so pool size stays stable
|
||||||
|
- Gated on `traffic` (reuses Pool/SemaphorePool)
|
||||||
|
- 2 tests (pool returns item + reconnects_broken_item)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- pool.rs: +Reconnectable +AutoReconnectPool +test
|
||||||
|
- lib.rs: export AutoReconnectPool, Reconnectable
|
||||||
|
|
||||||
|
Build: exit 0. Tests: 0 FAILED. Clippy: 0 new warnings.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Implementation Spec: Round 13 — COMPLETE
|
||||||
|
|
||||||
|
## New Feature
|
||||||
|
|
||||||
|
### AggregateError (mytheclipse-core, resiliency)
|
||||||
|
File: `crates/mytheclipse/src/aggregate_error.rs`
|
||||||
|
- Collects multiple `E: std::error::Error` from parallel/fan-out tasks into one
|
||||||
|
error — natural failure type for `join_all` + batch/fan-out resilience
|
||||||
|
- `empty()` / `with_context(..)` / push(E) / is_empty / len / iter
|
||||||
|
- `from_results(Vec<Result<V,E>>) -> Result<Vec<V>, AggregateError>` — collects
|
||||||
|
ALL errors, returns values when all Ok
|
||||||
|
- Display lists count + first error; From<Vec<Box<dyn Error>>>, Extend
|
||||||
|
- 3 tests
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- new: core/src/aggregate_error.rs
|
||||||
|
- core/lib.rs: +module+export AggregateError (resiliency)
|
||||||
|
|
||||||
|
Build: exit 0. Tests: 0 FAILED (97 core pass). Clippy: 0 new warnings.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Implementation Spec: Round 14 — COMPLETE
|
||||||
|
|
||||||
|
## New Feature
|
||||||
|
|
||||||
|
### parallel_map / parallel_map_unordered (mytheclipse-core, resiliency)
|
||||||
|
File: `crates/mytheclipse/src/parallel_map.rs`
|
||||||
|
- Bounded parallel map over a collection with a concurrency limit
|
||||||
|
(Semaphore) — removes manual `Semaphore + join_all` + error-aggregation
|
||||||
|
boilerplate that races easily by hand
|
||||||
|
- `parallel_map(items, concurrency, f) -> Result<Vec<T>, AggregateError>` —
|
||||||
|
results in input order; all tasks keep running on failure (fan-out), all
|
||||||
|
errors aggregated into one AggregateError
|
||||||
|
- `parallel_map_unordered` API-symmetry alias (input-ordered, documented)
|
||||||
|
- Requires I::Item/T: Send + 'static (tokio::spawn)
|
||||||
|
- 3 tests
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- new: core/src/parallel_map.rs
|
||||||
|
- core/lib.rs: +module+export parallel_map, parallel_map_unordered
|
||||||
|
|
||||||
|
Build: 0 errors. Tests: 0 FAILED (100 core pass). Clippy: 0 new warnings.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Implementation Spec: Round 15 — COMPLETE
|
||||||
|
|
||||||
|
## New Feature
|
||||||
|
|
||||||
|
### parallel_for_each (mytheclipse-core, resiliency)
|
||||||
|
File: `crates/mytheclipse/src/parallel_map.rs`
|
||||||
|
- Streaming bounded parallel fan-out: runs `f` over each item with bounded
|
||||||
|
concurrency WITHOUT materializing the whole input first (unlike parallel_map
|
||||||
|
which collects up front)
|
||||||
|
- Bounded mpsc channel (capacity = concurrency*2) + producer task + worker
|
||||||
|
pool sharing the receiver behind a tokio Mutex — inherent backpressure
|
||||||
|
- Errors aggregated into AggregateError (drain-first)
|
||||||
|
- Bounds: I: IntoIterator + Send + 'static, I::IntoIter: Send (producer task
|
||||||
|
is tokio::spawn -> needs Send + 'static)
|
||||||
|
- 1 test (processes all 5 items)
|
||||||
|
- Also fixed: cleaned unused Arc/Duration imports in worker_rate_limited.rs
|
||||||
|
(round-10 leftover)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- modified: core/src/parallel_map.rs (+parallel_for_each)
|
||||||
|
- core/lib.rs: export parallel_for_each
|
||||||
|
- queue/src/worker_rate_limited.rs: remove unused imports
|
||||||
|
|
||||||
|
Build: 0 errors. Tests: 0 FAILED (101 core pass). Clippy: 0 new warnings.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Implementation Spec: Round 16 — COMPLETE
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Make the round-7-15 abstractions actually usable — a single, runnable, wired
|
||||||
|
example showing high-level primitives composing together.
|
||||||
|
|
||||||
|
## New File
|
||||||
|
|
||||||
|
### examples/high_level.rs (mytheclipse-core)
|
||||||
|
`crates/mytheclipse/examples/high_level.rs`
|
||||||
|
- One realistic flow demoing, wired together:
|
||||||
|
1. RuntimeConfig::auto() — auto thread/core sizing from host CPU
|
||||||
|
2. parallel_map — bounded fan-out + AggregateError
|
||||||
|
3. RetryExt — ergonomic .retry() on a Future
|
||||||
|
4. ShutdownGuard — RAII exactly-once cleanup
|
||||||
|
5. AutoReconnectPool — self-healing resource pool (dead value replaced)
|
||||||
|
6. AutoMetricsServiceBuilder — auto latency/outcome metrics
|
||||||
|
- Run: `cargo run -p mytheclipse --features full --example high_level`
|
||||||
|
|
||||||
|
## Verified Output (real run, 8-core host)
|
||||||
|
```
|
||||||
|
1. RuntimeConfig::auto() -> worker=8, blocking=12, compute=8, io=4
|
||||||
|
2. parallel_map -> [10, 20, 30, 40, 50]
|
||||||
|
3. RetryExt with 4 attempts -> 42
|
||||||
|
4. ShutdownGuard fired 1x (exactly-once, even on unwind)
|
||||||
|
5. AutoReconnectPool first acquire -> 999
|
||||||
|
6. AutoMetrics -> 1 counters, 1 histograms
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- new: crates/mytheclipse/examples/high_level.rs
|
||||||
|
|
||||||
|
Build: exit 0. Run: succeeds (verified above).
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Implementation Spec: Round 17 — Race-Safety Stress Tests + Doctests
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Misi project: menghilangkan boilerplate race-condition. Bukti nyata bahwa
|
||||||
|
primitives aman di bawah kontensi tinggi. Tambahkan:
|
||||||
|
1. Stress/concurrency tests untuk primitives race-sensitive di core crate
|
||||||
|
2. Doctest `# Examples` untuk fitur round 7-16 agar docs.rs langsung berguna
|
||||||
|
|
||||||
|
## 1. New file: crates/mytheclipse/tests/race_stress.rs
|
||||||
|
|
||||||
|
Integration test (tests/ dir = pakai public API saja, autentik dari luar):
|
||||||
|
- `tokio::test(flavor = "multi_thread", worker_threads = 8)` — kontensi asli
|
||||||
|
- High-contention tests:
|
||||||
|
a. TokenBucket try_consume atomic — 64 tasks × 1000 consumes dari 1 bucket
|
||||||
|
capacity 100, rate tinggi → total consume ≤ capacity per window, no double
|
||||||
|
b. SemaphorePool acquire/release concurrent — 100 tasks acquire+release
|
||||||
|
cycle, final available == capacity, no leak
|
||||||
|
c. AutoReconnectPool — healthy probe retval, 50 concurrent acquire, semua
|
||||||
|
dapat item valid
|
||||||
|
d. RateLimitedQueue concurrent enqueue/dequeue — 8 worker × 1000 item,
|
||||||
|
total dequeue == total enqueue
|
||||||
|
e. ShutdownGuard exactly-once — 10 clones-ish concurrent drops → callback
|
||||||
|
count == 1 (via Arc<AtomicUsize>)
|
||||||
|
f. parallel_map 10k items concurrency 32 — hasil input-ordered, nilai benar
|
||||||
|
g. parallel_for_each 10k items concurrency 32 — side-effect count == 10k
|
||||||
|
h. AggregateError from_results merge 100 results mix ok/err — error count
|
||||||
|
benar, values semua lolos yang ok
|
||||||
|
- Assertions: `assert_eq!` pada counts; harness FAILS kalau race → flaky
|
||||||
|
|
||||||
|
## 2. Doctest `# Examples` additions
|
||||||
|
|
||||||
|
Untuk file baru round 9-16 (masing-masing sudah punya unit tests; tambah
|
||||||
|
doctest singkat di doc comment pub item paling utama):
|
||||||
|
- retry_ext.rs: `RetryExt::retry` contoh 1-liner
|
||||||
|
- auto_metrics_service.rs: AutoMetricsServiceBuilder contoh
|
||||||
|
- runtime_auto.rs: RuntimeConfig::auto contoh
|
||||||
|
- shutdown_guard.rs: ShutdownGuard contoh
|
||||||
|
- aggregate_error.rs: AggregateError::from_results contoh
|
||||||
|
- parallel_map.rs: parallel_map + parallel_for_each contoh
|
||||||
|
- pool.rs AutoReconnectPool: contoh
|
||||||
|
|
||||||
|
Doctest wajib compile: `cargo test --doc --workspace --all-features`
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- new: crates/mytheclipse/tests/race_stress.rs
|
||||||
|
- edit: parallel_map.rs, retry_ext.rs, auto_metrics_service.rs, runtime_auto.rs,
|
||||||
|
shutdown_guard.rs, aggregate_error.rs, pool.rs (doctest blocks)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
1. `cargo test -p mytheclipse --tests --all-features` — 0 FAILED
|
||||||
|
2. `cargo test -p mytheclipse --doc --all-features` — 0 FAILED
|
||||||
|
3. `cargo build --workspace --all-features` — exit 0
|
||||||
|
4. `cargo clippy --workspace --all-features` — 0 new warnings
|
||||||
|
5. Commit + push
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Implementation Spec: Round 19 — Criterion Benchmarks
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Buktikan klaim "secepat mungkin" (tujuan awal project) dengan benchmark
|
||||||
|
nyata. Ukur overhead primitives race-safe vs baseline naif, supaya user
|
||||||
|
tahu trade-off dan bisa memilih fitur dengan data.
|
||||||
|
|
||||||
|
## New files
|
||||||
|
|
||||||
|
### crates/mytheclipse/benches/primitives.rs
|
||||||
|
Criterion bench untuk primitives core (feature `full`):
|
||||||
|
- `parallel_map`: throughput 1000 item, concurrency 8 vs sequential loop
|
||||||
|
(pakai `black_box`)
|
||||||
|
- `retry_ext`: overhead `.retry()` success-first vs 2 retries
|
||||||
|
- `rate_limiter`: `RateLimiter::try_acquire` throughput (atomic CAS)
|
||||||
|
- `semaphore_pool`: acquire/release cycle throughput — bukti no-leak + low
|
||||||
|
overhead
|
||||||
|
- `aggregate_error`: `from_results` 1000 results all-ok vs 50% err
|
||||||
|
- `shutdown_guard`: new + drop cost
|
||||||
|
|
||||||
|
## Dependency
|
||||||
|
- dev-deps: `criterion = "0.5"` + `[[bench]]` harness = false
|
||||||
|
- `harness = false` di Cargo.toml bench section (criterion punya main sendiri)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
1. `cargo bench -p mytheclipse --bench primitives --all-features` — runs,
|
||||||
|
reports times
|
||||||
|
2. `cargo build --workspace --all-features` — exit 0
|
||||||
|
3. `cargo clippy --workspace --all-features` — 0 new warnings
|
||||||
|
4. Spec + commit + push
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Criterion 0.5 mendukung MSRV 1.60 — aman untuk rust-version 1.75.
|
||||||
|
- Bench tidak jalan di CI (hanya manual) — tidak mempengaruhi pipeline.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Implementation Spec: Round 2
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### 1. ServiceBuilder (mytheclipse-core)
|
||||||
|
File: `crates/mytheclipse/src/service_builder.rs`
|
||||||
|
- Builder that wraps async operations with retry + circuit breaker + timeout + rate limiter
|
||||||
|
- Fluent API: `.retry(config)`, `.circuit(config)`, `.timeout(dur)`, `.rate(rate, burst)`, `.concurrency(max)`, `.run(fut)`
|
||||||
|
- Feature gate: `resiliency` (uses existing retry/CircuitBreaker/timeout primitives)
|
||||||
|
- Integrates with metrics: records retries, circuit events, timeouts
|
||||||
|
|
||||||
|
### 2. DistributedLock (mytheclipse-core)
|
||||||
|
File: `crates/mytheclipse/src/dlock.rs`
|
||||||
|
- `DistributedLock` trait: `acquire(timeout)`, `release()`, `extend(lease_dur)`
|
||||||
|
- `InProcDistributedLock` impl using tokio Mutex + lease time tracking
|
||||||
|
- `RedisLock` impl (feature `redis`) — Redis SETNX with PX expiry
|
||||||
|
- Feature gate: `lifecycle` (uses existing leader election infra)
|
||||||
|
|
||||||
|
### 3. StreamingPipeline (mytheclipse-queue)
|
||||||
|
File: `crates/mytheclipse-queue/src/pipeline.rs`
|
||||||
|
- Pipe stages: `Stage<Input, Output>` trait with async `process(item) -> Output`
|
||||||
|
- Pipeline: `add_stage(impl Stage)`, `run(input_stream)`, `collect()`
|
||||||
|
- Backpressure: bounded channel between stages
|
||||||
|
- Feature gate: `in-memory` (uses tokio + std)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Implementation Spec: Round 20 — Auto Concurrency
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
`parallel_map` / `parallel_map_unordered` / `parallel_for_each` terima
|
||||||
|
`usize` (eksplisit, existing) ATAU `()` (auto dari host CPU cores). Tidak
|
||||||
|
perlu nama API baru — trait `ParallelConcurrency` resolve di call-site.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
- Trait `ParallelConcurrency`: `fn resolve(self) -> usize`
|
||||||
|
- impl `usize` → `self.max(1)` (behavior lama, backward compatible)
|
||||||
|
- impl `()` → `std::thread::available_parallelism()` fallback 1
|
||||||
|
- 3 fungsi berubah: `concurrency: usize` → `concurrency: C where C: ParallelConcurrency`
|
||||||
|
- `let n = concurrency.resolve();`
|
||||||
|
- Body tidak berubah (pakai `n`)
|
||||||
|
- Export trait di lib.rs
|
||||||
|
|
||||||
|
## Backward compat
|
||||||
|
Caller existing `parallel_map(items, 4, f)` tetap compile — `4` resolve ke
|
||||||
|
`usize` (satu-satunya impl integer). Literal inference OK karena trait bound
|
||||||
|
memaksa `usize`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- crates/mytheclipse/src/parallel_map.rs (trait + 3 signature)
|
||||||
|
- crates/mytheclipse/src/lib.rs (export ParallelConcurrency)
|
||||||
|
- crates/mytheclipse/examples/scaling_demo.rs (demo auto run)
|
||||||
|
- doctests: tambah contoh auto `()` di parallel_map & parallel_for_each
|
||||||
|
- tests: `auto_concurrency_uses_cpu_cores` (peak ≤ cores), hasil benar
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
1. `cargo test -p mytheclipse parallel --all-features` — 0 FAILED
|
||||||
|
2. `cargo test -p mytheclipse --test race_stress --all-features` — 0 FAILED
|
||||||
|
3. `cargo build --workspace --all-features` — exit 0
|
||||||
|
4. `cargo clippy --workspace --all-features` — 0 new
|
||||||
|
5. `cargo run --example scaling_demo` — auto run peak == cores
|
||||||
|
6. spec + commit + push
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Implementation Spec: Round 21 — CPU Parallel Compute Primitives
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Fitur parallel khusus CPU (rayon) yang bounded, panic-isolated, error-aggregated.
|
||||||
|
Melengkapi `compute()` (single call) dengan batch parallel + fork-join.
|
||||||
|
|
||||||
|
## New API (crates/mytheclipse/src/compute.rs, feature `compute`)
|
||||||
|
|
||||||
|
### 1. `compute_map<I, T, F>(items, f) -> Result<Vec<T>, ComputeErrors>`
|
||||||
|
- `pack_items` di rayon compute pool: `par_iter().map(f)` — bounded concurrency
|
||||||
|
otomatis (rayon work-stealing sizing = CPU cores), ordered output.
|
||||||
|
- `f: Fn(I::Item) -> Result<T, ComputeMapItemError>`:
|
||||||
|
- item error string → dikumpulkan
|
||||||
|
- panic per item di-catch (catch_unwind) → jadi error, pool survive
|
||||||
|
- `ComputeErrors { errors: Vec<String> }` — Display, Error, len, is_empty.
|
||||||
|
(Tidak pakai AggregateError — feature `compute` harus compile tanpa resiliency.)
|
||||||
|
- `I: IntoParallelIterator` (rayon) — work langsung di pool, tanpa materialize.
|
||||||
|
|
||||||
|
### 2. `compute_join<A, B, RA, RB>(a, b) -> Result<(RA, RB), MytheclipseError>`
|
||||||
|
- `rayon::join` wrapper di compute pool: 2 heavy closures run parallel.
|
||||||
|
- Panic-isolated (catch_unwind per branch) — pool survive, error jadi
|
||||||
|
ComputePanic.
|
||||||
|
|
||||||
|
### 3. `compute_par_for_each<I>(items, f) -> Result<(), ComputeErrors>`
|
||||||
|
- `par_iter().for_each` idiom — fire side-effects parallel di pool.
|
||||||
|
- Panic isolation per item.
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
- Reuse `context().compute_pool` (existing sizing: compute_threads dari
|
||||||
|
RuntimeConfig / available_parallelism) — konsisten dengan `compute()`.
|
||||||
|
- `rayon::ThreadPool::install` untuk semua — force run di pool.
|
||||||
|
- Panic isolation: `std::panic::catch_unwind` + AssertUnwindSafe per item
|
||||||
|
(sama seperti `compute()` yang sudah proven).
|
||||||
|
- Bounded = rayon work-stealing — concurrency = pool threads (CPU cores),
|
||||||
|
bukan item count. Tidak perlu semaphore.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- crates/mytheclipse/src/compute.rs (3 fungsi + error type)
|
||||||
|
- crates/mytheclipse/src/lib.rs (export)
|
||||||
|
- doctests: compute_map, compute_join, compute_par_for_each
|
||||||
|
- tests: unit di compute.rs
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
1. `cargo build --workspace --all-features` — exit 0
|
||||||
|
2. `cargo test -p mytheclipse compute --all-features` — 0 FAILED
|
||||||
|
3. `cargo test -p mytheclipse --doc --all-features` — 0 FAILED
|
||||||
|
4. `cargo clippy --workspace --all-features` — 0 new
|
||||||
|
5. spec + commit + push
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Implementation Spec: Round 3
|
||||||
|
|
||||||
|
## New Features (4)
|
||||||
|
|
||||||
|
### 1. ConfigValidator (mytheclipse-config)
|
||||||
|
File: `crates/mytheclipse-config/src/validate.rs`
|
||||||
|
- `ConfigValidator` trait: `fn validate(&self) -> Result<(), ValidationError>`
|
||||||
|
- `ConfigValidatorExt` trait: blanket impl for `T: ConfigValidator`
|
||||||
|
- Built-in validators: `validate_url`, `validate_port`, `validate_non_empty`, `validate_range`, `collect_failures`
|
||||||
|
- `ValidationFailure { path, message }` + `ValidationError` type alias
|
||||||
|
- Feature gate: `validation` (default)
|
||||||
|
- Tests: 17 (unit + doctest)
|
||||||
|
|
||||||
|
### 2. AsyncLifecycleManager (mytheclipse-core)
|
||||||
|
File: `crates/mytheclipse/src/lifecycle.rs`
|
||||||
|
- `AsyncLifecycleManager` composing `ShutdownManager` + `HealthRegistry`
|
||||||
|
- Methods: `register_health_check`, `check_health`, `shutdown_signal`, `start_health_loop`, `await_shutdown`, `request_shutdown`
|
||||||
|
- Feature gate: `lifecycle`
|
||||||
|
- Tests: 38 total (3 new in lifecycle.rs)
|
||||||
|
|
||||||
|
### 3. MetricsBridge (mytheclipse-core)
|
||||||
|
File: `crates/mytheclipse/src/metrics_bridge.rs`
|
||||||
|
- `MetricsBridge` — emits MetricsCollector snapshot to tracing
|
||||||
|
- `MetricsHealthCheck` — wraps MetricsCollector as HealthCheck (unhealthy if error counters > 0)
|
||||||
|
- Feature gate: `observability`
|
||||||
|
|
||||||
|
### 4. ServiceBuilder RateLimiter API (mytheclipse-core)
|
||||||
|
File: `crates/mytheclipse/src/service_builder.rs`
|
||||||
|
- `with_rate_limiter` fluent builder (already existed)
|
||||||
|
- `check_pre` performs rate-limit pre-acquire before calling service
|
||||||
|
- Returns `RunError::RateLimited` when rate limiter exhausted
|
||||||
|
|
||||||
|
## Build Status
|
||||||
|
- cargo build --workspace --all-features: OK
|
||||||
|
- cargo test --workspace --all-features: all pass (77+17+18+16+6+5+...)
|
||||||
|
- cargo clippy: 0 warnings on new code (pre-existing warnings in crypto/base64/cli only)
|
||||||
|
- Committed + pushed
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `Arc<HealthRegistry>` in AsyncLifecycleManager because HealthRegistry doesn't impl Clone
|
||||||
|
- Doctest marked `ignore` (async runtime not available in doctest context)
|
||||||
|
- Lint checker false-positives on `async fn` (edition 2015 phantom) but actual cargo build/tests pass
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Implementation Spec: Round 4
|
||||||
|
|
||||||
|
## Status: COMPLETE
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### 1. CircuitBreakerMetrics (circuit_breaker.rs)
|
||||||
|
- Added `CircuitSnapshot { state: CircuitState, failures: u64, successes: u64 }` struct
|
||||||
|
- Added `CircuitBreaker::snapshot() -> CircuitSnapshot` method (atomic load)
|
||||||
|
- Test: `snapshot_reflects_state_and_counts`
|
||||||
|
|
||||||
|
### 2. RetryStats (retry.rs)
|
||||||
|
- Added `RetryStats { attempts: u32, retries: u32, last_error: Option<String> }`
|
||||||
|
- Added `retry_with_stats()` returning `(Result, RetryStats)` (parallel to retry())
|
||||||
|
- Tests: 2 new
|
||||||
|
|
||||||
|
### 3. AsyncLifecycleManager (lifecycle.rs) — Round 3 carryover, verified
|
||||||
|
- Composes ShutdownManager + HealthRegistry + health loop
|
||||||
|
- Tests: 3
|
||||||
|
|
||||||
|
### 4. MetricsBridge (metrics_bridge.rs) — Round 3 carryover
|
||||||
|
- `MetricsBridge` emits MetricsCollector → tracing
|
||||||
|
- `MetricsHealthCheck` wraps collector as HealthCheck
|
||||||
|
- Tests: 2
|
||||||
|
|
||||||
|
## Fixes in round 4
|
||||||
|
- `HealthRegistry` wrapped in `Arc` in AsyncLifecycleManager (not Clone)
|
||||||
|
- Removed unused `span`/`Instrument` import in lifecycle.rs
|
||||||
|
- Fixed `op_ref` mutability in service_builder.rs
|
||||||
|
- Fixed `last_error` assertion (None on success) in retry test
|
||||||
|
- Fixed snapshot test assertions (successes not incremented in Closed state)
|
||||||
|
|
||||||
|
## Build Status
|
||||||
|
- cargo build --workspace --all-features: OK (2 pre-existing warnings in crypto/cli)
|
||||||
|
- cargo test --workspace --all-features: ALL PASS
|
||||||
|
- cargo clippy: 0 warnings on round-4 code (pre-existing in crypto/cli only)
|
||||||
|
- Committed + pushed
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Implementation Spec: Round 5
|
||||||
|
|
||||||
|
## Status: COMPLETE
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### 1. CircuitBreakerHealthCheck (mytheclipse-core, observability+resiliency)
|
||||||
|
- `CircuitBreakerHealthCheck` di metrics_bridge.rs — HealthCheck impl yang memetakan CircuitBreaker snapshot state → HealthStatus (Open→Unhealthy, HalfOpen→Degraded, Closed→Ok)
|
||||||
|
- Gated `#[cfg(feature="resiliency")]`; re-export gated `#[cfg(all(observability, resiliency))]`
|
||||||
|
- `observability` feature now implies `lifecycle` (needed for crate::health module access)
|
||||||
|
|
||||||
|
### 2. TypedKeyRegistry (mytheclipse-crypto, password)
|
||||||
|
- `TypedKeyRegistry<K,V>` di key_registry.rs — ID-based key lookup + rotation + revoke, wraps KeyRing
|
||||||
|
- `key_for(id) -> Option<&K>`, `rotate_with_id(id, key)`, `revoke(id)`
|
||||||
|
|
||||||
|
### 3. MetricsHttpHandler (mytheclipse-http, metrics-http)
|
||||||
|
- new feature `metrics-http` (axum + tower + mytheclipse/observability)
|
||||||
|
- `metrics_routes(collector)` → Router serving /metrics (Prometheus text) + /
|
||||||
|
- added tower dep (util), ServiceExt import in test module
|
||||||
|
- 1 test via ServiceExt::oneshot
|
||||||
|
|
||||||
|
### 4. BatchProcessor (mytheclipse-queue, in-memory)
|
||||||
|
- `BatchJobHandler` trait — handle Vec<Job> atomically
|
||||||
|
- `BatchConfig` { batch_size, batch_timeout, concurrency }
|
||||||
|
- `BatchProcessor<Q>` — accumulates jobs per topic, flushes on size/timeout
|
||||||
|
- 2 tests: flush_on_batch_size, flush_on_timeout
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- cargo build --workspace --all-features → exit 0
|
||||||
|
- cargo test --workspace --all-features → all pass (160+ tests)
|
||||||
|
- cargo clippy --workspace --all-features → no new warnings
|
||||||
|
- commit + push: f02a1ce
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Implementation Spec: Round 6
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### 1. HealthCheckedPool (mytheclipse-core, observability+traffic)
|
||||||
|
File: `crates/mytheclipse/src/pool_health.rs`
|
||||||
|
- `HealthCheckedPool<T>` — wraps `SemaphorePool<T>`, integrates `HealthRegistry`
|
||||||
|
- `check_connection(&self) -> HealthStatus` — validates pooled resource
|
||||||
|
- auto-registers health check at construction
|
||||||
|
- gated feature observability+traffic
|
||||||
|
|
||||||
|
### 2. HkdfKeyDeriver (mytheclipse-crypto, derivation feature)
|
||||||
|
File: `crates/mytheclipse-crypto/src/hkdf.rs`
|
||||||
|
- `HkdfKeyDeriver` — HKDF-SHA256 (RFC 5869) from master secret
|
||||||
|
- `derive_key(&self, purpose: &str, output_len) -> Vec<u8>` — context-specific sub-key
|
||||||
|
- domain separation via purpose as info
|
||||||
|
- gated feature "derivation"
|
||||||
|
|
||||||
|
### 3. BackpressureEnqueue (mytheclipse-queue, in-memory)
|
||||||
|
File: `crates/mytheclipse-queue/src/backpressure.rs`
|
||||||
|
- `BackpressureEnforcer` — tracks in-flight count, enforces max
|
||||||
|
- `enqueue_or_nack(queue, topic, payload, max_inflight) -> Result<(), BackpressureError>`
|
||||||
|
- non-blocking: returns BackpressureError when at capacity
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- build + test + clippy + commit + push
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Implementation Spec: Round 7 — COMPLETE
|
||||||
|
|
||||||
|
3 fitur implementasi selesai:
|
||||||
|
- `BgJoiner` (core, lifecycle) — graceful task join, 2 tests
|
||||||
|
- `MiddlewarePipeline` (core, observability+resiliency) — composable async mw stack, 2 tests
|
||||||
|
- `RateLimitedQueue` (queue) — token-bucket rate-limited enqueue wrapper, 2 tests + QueueError::RateLimit variant
|
||||||
|
|
||||||
|
Build: `cargo build --workspace --all-features` exit 0.
|
||||||
|
Tests: semua pass (0 FAILED).
|
||||||
|
Clippy: 0 new warnings.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Round 8 — COMPLETE
|
||||||
|
|
||||||
|
## New Feature
|
||||||
|
### ResilientHttpClient (mytheclipse-http, resilience feature)
|
||||||
|
- File: `crates/mytheclipse-http/src/resilient_client.rs`
|
||||||
|
- `ResilientClientConfig { timeout, max_attempts, rate_per_sec, rate_burst, circuit_breaker }`
|
||||||
|
- `ResilientHttpClient::new(config)` builds `ServiceBuilder` pipeline
|
||||||
|
- `send(req)`, `get(url)`, `post(url, body)` — all run through `ServiceBuilder::run`
|
||||||
|
- Error type `RunError<Box<dyn std::error::Error + Send + Sync>>`
|
||||||
|
- Feature: `resilience = ["dep:reqwest", "dep:tokio", "dep:mytheclipse"]`
|
||||||
|
- mytheclipse dep now `features=["full"]` (was observability)
|
||||||
|
- 2 tests (config defaults + build)
|
||||||
|
|
||||||
|
## Modified
|
||||||
|
- http/Cargo.toml — resilience feature + mytheclipse full features
|
||||||
|
- http/lib.rs — module + re-export
|
||||||
|
- core/lib.rs — pub use RunError, ServiceConfig (needed by http crate)
|
||||||
|
- error.rs — RateLimit(String) variant (queue crate, round 6 carryover)
|
||||||
|
|
||||||
|
## Build: exit 0. Tests: 0 FAILED. Clippy: 0 new warnings.
|
||||||
|
|
||||||
|
## Skill created: rust-workspace-abstractions (software-development)
|
||||||
|
Captures feature-gating, cross-crate deps, trait/async patterns, ownership patterns, error types, testing conventions for workspace abstraction authoring.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Implementation Spec: Round 9 — COMPLETE
|
||||||
|
|
||||||
|
## New Feature
|
||||||
|
|
||||||
|
### RetryExt (mytheclipse-core, resiliency)
|
||||||
|
File: `crates/mytheclipse/src/retry_ext.rs`
|
||||||
|
- `RetryExt` trait — `.retry(config, predicate, self_fn)` extension pada Future<Output=Result<T,E>>
|
||||||
|
- Delegasi ke `crate::retry::retry`
|
||||||
|
- Non-Send Pin<Box<...>> return (single-threaded test OK)
|
||||||
|
- 1 test (retries_then_succeeds)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- new: retry_ext.rs
|
||||||
|
- core/lib.rs: +module +pub use RetryExt
|
||||||
|
|
||||||
|
Build: exit 0. Tests: 0 FAILED. Clippy: 0 new warnings.
|
||||||
+27
-11
@@ -1,18 +1,34 @@
|
|||||||
{
|
{
|
||||||
"branches": ["main"],
|
"branches": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@semantic-release/commit-analyzer",
|
"@semantic-release/commit-analyzer",
|
||||||
"@semantic-release/release-notes-generator",
|
"@semantic-release/release-notes-generator",
|
||||||
"@semantic-release/changelog",
|
"@semantic-release/changelog",
|
||||||
["@semantic-release/exec", {
|
[
|
||||||
"prepareCmd": "sed -i 's/^version = \\\"[^\\\"]*\\\"/version = \\\"${nextRelease.version}\\\"/' Cargo.toml && cargo check"
|
"@semantic-release/exec",
|
||||||
}],
|
{
|
||||||
["@semantic-release/git", {
|
"prepareCmd": "for f in crates/*/Cargo.toml; do sed -i 's/^version = \\\"[^\\\"]*\\\"/version = \\\"${nextRelease.version}\\\"/' \"$f\"; done && cargo check --workspace",
|
||||||
"assets": ["Cargo.toml", "Cargo.lock", "CHANGELOG.md"],
|
"successCmd": "gh api -X POST repos/asepharyana/mytheclipse/actions/workflows/publish.yml/dispatches -f ref=main -f 'inputs[tag]=v${nextRelease.version}'"
|
||||||
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
}
|
||||||
}],
|
],
|
||||||
["@semantic-release/github", {
|
[
|
||||||
"assets": []
|
"@semantic-release/git",
|
||||||
}]
|
{
|
||||||
|
"assets": [
|
||||||
|
"crates/*/Cargo.toml",
|
||||||
|
"Cargo.lock",
|
||||||
|
"CHANGELOG.md"
|
||||||
|
],
|
||||||
|
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/github",
|
||||||
|
{
|
||||||
|
"assets": []
|
||||||
|
}
|
||||||
|
]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+187
-3
@@ -1,11 +1,195 @@
|
|||||||
# [0.2.0](https://github.com/asepharyana/corex/compare/v0.1.0...v0.2.0) (2026-08-28)
|
## [1.21.2](https://github.com/asepharyana/mytheclipse/compare/v1.21.1...v1.21.2) (2026-08-30)
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
* **release:** remove --locked from cargo check in release prepare ([90322b5](https://github.com/asepharyana/corex/commit/90322b5f970aaf48b6da0adaf89c2f516aec0e4c))
|
* **cache:** align redis dep to 0.32 for deadpool unification ([901fc51](https://github.com/asepharyana/mytheclipse/commit/901fc51f7b3bf4b2ab433db2e35303722778cc76))
|
||||||
|
|
||||||
|
## [1.21.1](https://github.com/asepharyana/mytheclipse/compare/v1.21.0...v1.21.1) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **ci:** restore full CI green — test-matrix, clippy, rustfmt, and rustdoc gates ([1dfc6d6](https://github.com/asepharyana/mytheclipse/commit/1dfc6d68657e5b002896f400d744b052800c9285))
|
||||||
|
|
||||||
|
# [1.21.0](https://github.com/asepharyana/mytheclipse/compare/v1.20.0...v1.21.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
* **release:** add semantic-release auto versioning ([12a1ab1](https://github.com/asepharyana/corex/commit/12a1ab10d04dec40f8b93edb51bb6104f6a75bcb))
|
* CPU parallel compute primitives — compute_map, compute_join, compute_par_for_each ([e148944](https://github.com/asepharyana/mytheclipse/commit/e14894479d8ca716a9decf2c1403359fdc376717))
|
||||||
|
|
||||||
|
# [1.20.0](https://github.com/asepharyana/mytheclipse/compare/v1.19.0...v1.20.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* ParallelConcurrency — auto-size concurrency from CPU cores ([d00881c](https://github.com/asepharyana/mytheclipse/commit/d00881c2b96fa29ee0eaa5f43a7c7af90983e802))
|
||||||
|
|
||||||
|
# [1.19.0](https://github.com/asepharyana/mytheclipse/compare/v1.18.0...v1.19.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* criterion benchmarks proving primitive overhead is negligible ([5df04d7](https://github.com/asepharyana/mytheclipse/commit/5df04d75126d71d0790028c4a215acb571f25616))
|
||||||
|
|
||||||
|
# [1.18.0](https://github.com/asepharyana/mytheclipse/compare/v1.17.0...v1.18.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-15 abstractions — parallel_for_each streaming fan-out ([730245e](https://github.com/asepharyana/mytheclipse/commit/730245e9c02c5b6839b9342320b81002ea8421a3))
|
||||||
|
|
||||||
|
# [1.17.0](https://github.com/asepharyana/mytheclipse/compare/v1.16.0...v1.17.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-14 abstractions — parallel_map bounded fan-out ([bb4998d](https://github.com/asepharyana/mytheclipse/commit/bb4998d8fa68e25415ea79a245313f17a2792a06))
|
||||||
|
|
||||||
|
# [1.16.0](https://github.com/asepharyana/mytheclipse/compare/v1.15.0...v1.16.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-13 abstractions — AggregateError for parallel fan-out ([ff5fbe4](https://github.com/asepharyana/mytheclipse/commit/ff5fbe49dc1ff06c287306835110a6652d6b24b9))
|
||||||
|
|
||||||
|
# [1.15.0](https://github.com/asepharyana/mytheclipse/compare/v1.14.0...v1.15.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-12 abstractions — AutoReconnectPool, Reconnectable ([ddb2c2f](https://github.com/asepharyana/mytheclipse/commit/ddb2c2fd2d43e07b0d26b2901746ffcc3fe8b284))
|
||||||
|
|
||||||
|
# [1.14.0](https://github.com/asepharyana/mytheclipse/compare/v1.13.0...v1.14.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-11 abstractions — RuntimeConfig auto thread/core, ShutdownGuard RAII ([74abe71](https://github.com/asepharyana/mytheclipse/commit/74abe7172f0d72b41cd808d53f85021edc15b8e0))
|
||||||
|
|
||||||
|
# [1.13.0](https://github.com/asepharyana/mytheclipse/compare/v1.12.0...v1.13.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-10 abstractions — AutoMetricsServiceBuilder, RateLimitedWorkerPool ([2f445d3](https://github.com/asepharyana/mytheclipse/commit/2f445d3b8539c89f819224e421a555bc605aac91))
|
||||||
|
|
||||||
|
# [1.12.0](https://github.com/asepharyana/mytheclipse/compare/v1.11.0...v1.12.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-9 abstractions — RetryExt ergonomic retry, ResilientHttpClient ([7a063fa](https://github.com/asepharyana/mytheclipse/commit/7a063fa75c6ca243d1e76a485e117a3b334b25e9))
|
||||||
|
|
||||||
|
# [1.11.0](https://github.com/asepharyana/mytheclipse/compare/v1.10.0...v1.11.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-8 abstractions — ResilientHttpClient, MiddlewarePipeline, BgJoiner ([076b0bb](https://github.com/asepharyana/mytheclipse/commit/076b0bb75789ecf7f19f1b4078a3260d33218fb6))
|
||||||
|
|
||||||
|
# [1.10.0](https://github.com/asepharyana/mytheclipse/compare/v1.9.0...v1.10.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-7 abstractions — BgJoiner, MiddlewarePipeline, RateLimitedQueue ([851c8c4](https://github.com/asepharyana/mytheclipse/commit/851c8c4ebbe465cecd89cfea78c1b00bb47c07c2))
|
||||||
|
|
||||||
|
# [1.9.0](https://github.com/asepharyana/mytheclipse/compare/v1.8.0...v1.9.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-6 abstractions — HealthCheckedPool, HkdfKeyDeriver, BackpressureEnforcer ([a03db38](https://github.com/asepharyana/mytheclipse/commit/a03db38c5ccabead51fa49d2001b0cd94a9dd66e))
|
||||||
|
|
||||||
|
# [1.8.0](https://github.com/asepharyana/mytheclipse/compare/v1.7.0...v1.8.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-5 abstractions — BatchProcessor, CircuitBreakerHealthCheck, TypedKeyRegistry, MetricsHttpHandler ([97b5e02](https://github.com/asepharyana/mytheclipse/commit/97b5e02820674a5b61a2d396f95df07f2b4fd735))
|
||||||
|
|
||||||
|
# [1.7.0](https://github.com/asepharyana/mytheclipse/compare/v1.6.0...v1.7.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-5 abstractions — CircuitBreakerHealthCheck, TypedKeyRegistry, MetricsHttpHandler ([510aadc](https://github.com/asepharyana/mytheclipse/commit/510aadc066a428c1627a38bdb22e4f0440cc01b3))
|
||||||
|
|
||||||
|
# [1.6.0](https://github.com/asepharyana/mytheclipse/compare/v1.5.0...v1.6.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-4 metrics for circuit breaker + retry stats + lifecycle fixes ([717e690](https://github.com/asepharyana/mytheclipse/commit/717e6905cd7a3f7389b455d01054a2c2cc28befd))
|
||||||
|
|
||||||
|
# [1.5.0](https://github.com/asepharyana/mytheclipse/compare/v1.4.1...v1.5.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* round-3 abstractions — ConfigValidator, AsyncLifecycleManager, MetricsBridge, rate limiter pre-acquire ([1ea3b35](https://github.com/asepharyana/mytheclipse/commit/1ea3b3558143bd07168c3be89653fbeb9c38930a))
|
||||||
|
|
||||||
|
## [1.4.1](https://github.com/asepharyana/mytheclipse/compare/v1.4.0...v1.4.1) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* clippy clean for round-2 (pipeline module export, lint cleanup) ([1981544](https://github.com/asepharyana/mytheclipse/commit/198154442c4297c94e8743caec81294b478c0d3a))
|
||||||
|
|
||||||
|
# [1.4.0](https://github.com/asepharyana/mytheclipse/compare/v1.3.5...v1.4.0) (2026-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add 4 new crates (queue, tracing, http, cli) + enhancements to existing crates ([8106f89](https://github.com/asepharyana/mytheclipse/commit/8106f8943ebe83f7348b9fde3fbd2e347018604e))
|
||||||
|
|
||||||
|
## [1.3.5](https://github.com/asepharyana/mytheclipse/compare/v1.3.4...v1.3.5) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **cache:** honor sub-second Redis TTL via PSETEX + document clear() safety ([2c9367a](https://github.com/asepharyana/mytheclipse/commit/2c9367a83c2dd01b1e197ec33215a6c7d3755fa2))
|
||||||
|
|
||||||
|
## [1.3.4](https://github.com/asepharyana/mytheclipse/compare/v1.3.3...v1.3.4) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **cache,storage:** harden cache bounds + atomic disk writes ([b6f138b](https://github.com/asepharyana/mytheclipse/commit/b6f138b90d67c9531b5a58993e8ec750e5eec57f))
|
||||||
|
|
||||||
|
## [1.3.3](https://github.com/asepharyana/mytheclipse/compare/v1.3.2...v1.3.3) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **publish:** trim mytheclipse keywords to 5 to satisfy crates.io limit ([5f1e3ac](https://github.com/asepharyana/mytheclipse/commit/5f1e3ace5c8cb10881e30f550f51b7d845b24edd))
|
||||||
|
|
||||||
|
## [1.3.2](https://github.com/asepharyana/mytheclipse/compare/v1.3.1...v1.3.2) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **ci:** gate cache & storage crate doctests behind their features ([5717f8a](https://github.com/asepharyana/mytheclipse/commit/5717f8aaaae34cb66cdbfc31f4982c93816ee5a3))
|
||||||
|
|
||||||
|
## [1.3.1](https://github.com/asepharyana/mytheclipse/compare/v1.3.0...v1.3.1) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **ci:** gate event crate doctest behind mem feature and apply rustfmt ([1994115](https://github.com/asepharyana/mytheclipse/commit/19941156b43ec58370d0d1369174ec84400e9bc9))
|
||||||
|
|
||||||
|
# [1.3.0](https://github.com/asepharyana/mytheclipse/compare/v1.2.0...v1.3.0) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add corex-storage crate for unified storage abstraction ([db4f277](https://github.com/asepharyana/mytheclipse/commit/db4f277336d1e32cb6a2ddd86ac37ae9789fa4f8))
|
||||||
|
|
||||||
|
# [1.2.0](https://github.com/asepharyana/mytheclipse/compare/v1.1.0...v1.2.0) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add panic tracking and logging with PanicTracker ([d119821](https://github.com/asepharyana/mytheclipse/commit/d1198213391710ccc6f2c108b15aa4526380423d))
|
||||||
|
|||||||
Generated
+5495
-19
File diff suppressed because it is too large
Load Diff
+14
-40
@@ -1,40 +1,14 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "mytheclipse"
|
members = [
|
||||||
version = "0.2.0"
|
"crates/mytheclipse",
|
||||||
edition = "2021"
|
"crates/mytheclipse-cache",
|
||||||
rust-version = "1.75"
|
"crates/mytheclipse-storage",
|
||||||
license = "MIT OR Apache-2.0"
|
"crates/mytheclipse-event",
|
||||||
repository = "https://github.com/asepharyana/corex"
|
"crates/mytheclipse-config",
|
||||||
homepage = "https://github.com/asepharyana/corex"
|
"crates/mytheclipse-crypto",
|
||||||
documentation = "https://docs.rs/mytheclipse"
|
"crates/mytheclipse-queue",
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
"crates/mytheclipse-tracing",
|
||||||
description = "Resource-aware abstractions for async I/O, heavy compute, and background queue management."
|
"crates/mytheclipse-http",
|
||||||
readme = "README.md"
|
"crates/mytheclipse-cli",
|
||||||
keywords = ["async", "concurrency", "rayon", "tokio", "resource-management"]
|
]
|
||||||
categories = ["asynchronous", "concurrency", "rust-patterns"]
|
resolver = "2"
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tokio = { version = "1.53", features = ["full"], optional = true }
|
|
||||||
rayon = { version = "1.12", optional = true }
|
|
||||||
num_cpus = "1.17"
|
|
||||||
tracing = "0.1"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio = { version = "1.53", features = ["full"] }
|
|
||||||
tracing-subscriber = "0.3"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
io = ["dep:tokio"]
|
|
||||||
compute = ["dep:rayon"]
|
|
||||||
bg = ["dep:tokio"]
|
|
||||||
full = ["io", "compute", "bg"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "main"
|
|
||||||
path = "examples/main.rs"
|
|
||||||
required-features = ["full"]
|
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
|
||||||
all-features = true
|
|
||||||
rustdoc-args = ["--cfg", "docsrs"]
|
|
||||||
@@ -1,81 +1,68 @@
|
|||||||
# mytheclipse
|
# mytheclipse
|
||||||
|
|
||||||
[](https://crates.io/crates/mytheclipse)
|
A personal collection of Rust abstractions for building resource-aware,
|
||||||
[](https://docs.rs/mytheclipse)
|
resilient, and well-instrumented applications without hand-rolling the same
|
||||||
[](LICENSE-MIT)
|
plumbing every time — organized as a Cargo workspace, one focused crate per
|
||||||
|
concern.
|
||||||
|
|
||||||
Resource-aware execution primitives for Rust: async I/O, heavy compute, and background queue management, sized automatically from the host's logical core count and exposed through a single, lazily-initialized engine context.
|
[](crates/mytheclipse/LICENSE-MIT)
|
||||||
|
|
||||||
## Resource Sizing
|
## Crates
|
||||||
|
|
||||||
Given $N$ logical cores (via `num_cpus::get()`):
|
| Crate | Description | Docs |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| [`mytheclipse`](crates/mytheclipse) | Resource-aware execution primitives (async I/O, compute, background queues), resiliency (retry, circuit breaker, timeout), traffic control (rate limiter, backpressure, concurrency limiter), lifecycle (graceful shutdown, cron, async lifecycle manager, distributed lock), and observability (metrics, panic tracking, metrics-to-health bridge). | [README](crates/mytheclipse/README.md) |
|
||||||
|
| [`mytheclipse-cache`](crates/mytheclipse-cache) | Unified multi-layer (L1/L2) cache abstraction: in-memory or Moka L1, Redis/Valkey L2, cache-aside read-through. | [README](crates/mytheclipse-cache/README.md) |
|
||||||
|
| [`mytheclipse-storage`](crates/mytheclipse-storage) | Unified storage & file system abstraction: one driver interface over local disk, S3/MinIO, and Google Cloud Storage, stream-based. | [README](crates/mytheclipse-storage/README.md) |
|
||||||
|
| [`mytheclipse-event`](crates/mytheclipse-event) | Unified events & message bus abstraction: in-memory pub/sub dispatcher plus RabbitMQ and NATS broker adapters behind one trait. | [README](crates/mytheclipse-event/README.md) |
|
||||||
|
| [`mytheclipse-config`](crates/mytheclipse-config) | Type-safe, dynamic configuration engine: load `.env`/YAML/JSON/TOML into typed structs, with hot-reload and typed validation. | [README](crates/mytheclipse-config/README.md) |
|
||||||
|
| [`mytheclipse-crypto`](crates/mytheclipse-crypto) | Safe hashing (Argon2id), encryption (AES-256-GCM), JWT and PASETO tokens, with key rotation support. | [README](crates/mytheclipse-crypto/README.md) |
|
||||||
|
| [`mytheclipse-queue`](crates/mytheclipse-queue) | Unified job queue abstraction with WorkerPool executor, retry/backoff, and dead-letter support. Backends: in-memory, Redis, NATS, PostgreSQL. | [README](crates/mytheclipse-queue/README.md) |
|
||||||
|
| [`mytheclipse-tracing`](crates/mytheclipse-tracing) | Pre-built tracing subscriber layers with env filtering and optional OTLP/Jaeger/Zipkin export. | [README](crates/mytheclipse-tracing/README.md) |
|
||||||
|
| [`mytheclipse-http`](crates/mytheclipse-http) | HTTP client and server abstraction with built-in retry, circuit breaker, timeout, and rate limiting. | [README](crates/mytheclipse-http/README.md) |
|
||||||
|
| [`mytheclipse-cli`](crates/mytheclipse-cli) | CLI framework for mytheclipse applications with built-in subcommands (serve, worker, migrate, health, version). | [README](crates/mytheclipse-cli/README.md) |
|
||||||
|
|
||||||
| Subsystem | Sizing Formula | Default on 8 cores | Backing Primitive |
|
Every crate follows the same philosophy: **one small interface, pluggable
|
||||||
| :--- | :--- | :--- | :--- |
|
backends behind feature flags, and a working default that needs no external
|
||||||
| **Async I/O** | $N$ | 8 | Ambient `tokio::spawn` + `tracing` span |
|
service to build or test.** Distributed backends (Redis, S3, GCS, RabbitMQ,
|
||||||
| **Compute** | $\max(1, N - 1)$ | 7 | Sized `rayon::ThreadPool` + `catch_unwind` |
|
NATS) are feature-gated and their integration tests are `#[ignore]`d unless
|
||||||
| **Background Queue** | $\max(2, \lfloor N / 2 \rfloor)$ | 4 | `tokio::sync::Semaphore` + `tokio::spawn` |
|
the corresponding environment variables point at a live service.
|
||||||
|
|
||||||
## Features
|
## Getting started
|
||||||
|
|
||||||
- **`io`**: enables `mytheclipse::spawn_io`, instrumented async task spawning.
|
Each crate is published independently; add the ones you need:
|
||||||
- **`compute`**: enables `mytheclipse::compute`, panic-isolated execution on a sized Rayon pool.
|
|
||||||
- **`bg`**: enables `mytheclipse::spawn_bg`, semaphore-bounded background tasks.
|
|
||||||
- **`full`**: enables all three subsystems.
|
|
||||||
|
|
||||||
Zero features enabled by default (`default = []`), so you only pull in the dependencies your application actually uses.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
Add to your `Cargo.toml`:
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mytheclipse = { version = "0.1", features = ["full"] }
|
mytheclipse = { version = "1", features = ["full"] }
|
||||||
|
mytheclipse-cache = "0.1"
|
||||||
|
mytheclipse-storage = { version = "0.1", features = ["s3"] }
|
||||||
|
mytheclipse-event = { version = "0.1", features = ["nats"] }
|
||||||
|
mytheclipse-config = "0.1"
|
||||||
|
mytheclipse-crypto = "0.1"
|
||||||
```
|
```
|
||||||
|
|
||||||
Use the entry points directly:
|
See each crate's own README (linked above) for usage examples and the full
|
||||||
|
feature-flag list.
|
||||||
|
|
||||||
```rust
|
## Development
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
// Optional explicit bootstrap: logs or validates resource sizing upfront.
|
|
||||||
// Omit it and the first call to any primitive below will initialize it lazily.
|
|
||||||
let ctx = mytheclipse::init();
|
|
||||||
println!(
|
|
||||||
"io_threads={} compute_threads={} bg_concurrency={}",
|
|
||||||
ctx.io_threads, ctx.compute_threads, ctx.bg_concurrency
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1. Async I/O (instrumented with tracing)
|
This is a Cargo workspace; run commands from the repository root:
|
||||||
let io = mytheclipse::spawn_io(async {
|
|
||||||
// ... network / disk work ...
|
|
||||||
42
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Heavy Compute (isolated from worker panics)
|
|
||||||
let sum = mytheclipse::compute(|| (1..=1_000_000u64).sum::<u64>())?;
|
|
||||||
|
|
||||||
// 3. Background Queue (concurrency-bounded)
|
|
||||||
let bg = mytheclipse::spawn_bg(async {
|
|
||||||
// ... deferred cleanup / telemetry ...
|
|
||||||
}).await;
|
|
||||||
|
|
||||||
let _ = (io.await, bg.await);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running the Example
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --example main --features full
|
cargo build --workspace --all-features
|
||||||
|
cargo test --workspace --all-features
|
||||||
|
cargo clippy --workspace --all-features -- -D warnings
|
||||||
|
cargo fmt --all --check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or target a single crate with `-p <name>`, e.g. `cargo test -p mytheclipse-cache`.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Licensed under either of:
|
Licensed under either of:
|
||||||
|
|
||||||
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
|
- Apache License, Version 2.0 ([LICENSE-APACHE](crates/mytheclipse/LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
|
||||||
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
|
- MIT license ([LICENSE-MIT](crates/mytheclipse/LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
|
||||||
|
|
||||||
at your option.
|
at your option.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
|
|||||||
|
[package]
|
||||||
|
name = "mytheclipse-cache"
|
||||||
|
version = "1.21.2"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.75"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/asepharyana/mytheclipse"
|
||||||
|
homepage = "https://github.com/asepharyana/mytheclipse"
|
||||||
|
documentation = "https://docs.rs/mytheclipse-cache"
|
||||||
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
|
description = "Unified multi-layer cache abstraction: L1/L2 caching, cache-aside and auto-refresh, with pluggable backends."
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["cache", "lru", "redis", "multilayer", "cache-aside"]
|
||||||
|
categories = ["caching", "asynchronous"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["l1-memory", "cache-aside"]
|
||||||
|
# L1 (in-process) backends.
|
||||||
|
l1-memory = []
|
||||||
|
l1-moka = ["l1-memory", "dep:moka"]
|
||||||
|
# L2 (distributed) backends.
|
||||||
|
l2-redis = ["l1-memory", "dep:redis"]
|
||||||
|
# Cache-aside + auto-refresh helper.
|
||||||
|
cache-aside = ["l1-memory", "dep:serde", "dep:serde_json", "dep:tokio"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tracing = "0.1"
|
||||||
|
# Required unconditionally: the core `Cache` trait (always compiled) uses it.
|
||||||
|
async-trait = "0.1"
|
||||||
|
serde = { version = "1", features = ["derive"], optional = true }
|
||||||
|
serde_json = { version = "1", optional = true }
|
||||||
|
|
||||||
|
# L1: Moka (high-performance in-memory cache).
|
||||||
|
moka = { version = "0.12", default-features = false, features = ["future"], optional = true }
|
||||||
|
|
||||||
|
# L2: Redis/Valkey async client (multiplexed connection).
|
||||||
|
redis = { version = "0.32", default-features = false, features = ["tokio-comp"], optional = true }
|
||||||
|
tokio = { version = "1.53", features = ["sync", "rt"], optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.53", features = ["full"] }
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# mytheclipse-cache
|
||||||
|
|
||||||
|
A unified multi-layer cache abstraction so your app isn't locked to one cache
|
||||||
|
provider. Combines an in-process **L1** cache with a distributed **L2** cache
|
||||||
|
(e.g. Redis/Valkey) behind one simple `get`/`set`/`invalidate` API, plus a
|
||||||
|
**cache-aside / auto-refresh** helper.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `l1-memory` (default) — zero-dependency in-process cache.
|
||||||
|
- `l1-moka` — high-performance Moka-backed L1 with TTL/max-capacity.
|
||||||
|
- `l2-redis` — Redis/Valkey L2 via `fred`.
|
||||||
|
- `cache-aside` (default) — read-through cache-aside helper.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use mytheclipse_cache::{Cache, MemoryCache, MultiLayerCache, CacheAside};
|
||||||
|
|
||||||
|
let cache = MultiLayerCache::new(
|
||||||
|
MemoryCache::new(), // L1
|
||||||
|
MemoryCache::new(), // L2 (use RedisCache in production)
|
||||||
|
);
|
||||||
|
cache.set("k", b"v".to_vec(), None).await.unwrap();
|
||||||
|
let v = cache.get("k").await.unwrap();
|
||||||
|
|
||||||
|
// Cache-aside: fill misses from a source of truth.
|
||||||
|
let aside = CacheAside::new(
|
||||||
|
MemoryCache::new(),
|
||||||
|
|key| async move { Some(format!("data-for-{key}").into_bytes()) },
|
||||||
|
);
|
||||||
|
let _ = aside.get("orders:42").await.unwrap();
|
||||||
|
```
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//! Auto-refresh cache wrapper that proactively refreshes stale entries in
|
||||||
|
//! the background, eliminating thundering-herd on cache miss.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::traits::Cache;
|
||||||
|
use crate::CacheError;
|
||||||
|
|
||||||
|
/// A cache wrapper that refreshes entries in the background before they expire.
|
||||||
|
///
|
||||||
|
/// When a `get` returns a `None`, the wrapper triggers a background refresh
|
||||||
|
/// (via `refresh_fn`) while still returning the miss to the caller.
|
||||||
|
pub struct AutoRefreshCache<C, F, Fut>
|
||||||
|
where
|
||||||
|
C: Cache + Clone + Send + Sync + 'static,
|
||||||
|
F: Fn(String) -> Fut + Send + Sync + 'static,
|
||||||
|
Fut: std::future::Future<Output = Result<Vec<u8>, CacheError>> + Send + 'static,
|
||||||
|
{
|
||||||
|
inner: C,
|
||||||
|
refresh_fn: Arc<F>,
|
||||||
|
refresh_after: Duration,
|
||||||
|
refreshing: Arc<Mutex<std::collections::HashSet<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C, F, Fut> AutoRefreshCache<C, F, Fut>
|
||||||
|
where
|
||||||
|
C: Cache + Clone + Send + Sync + 'static,
|
||||||
|
F: Fn(String) -> Fut + Send + Sync + 'static,
|
||||||
|
Fut: std::future::Future<Output = Result<Vec<u8>, CacheError>> + Send + 'static,
|
||||||
|
{
|
||||||
|
/// Creates a new auto-refresh wrapper.
|
||||||
|
pub fn new(inner: C, refresh_fn: F, refresh_after: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
refresh_fn: Arc::new(refresh_fn),
|
||||||
|
refresh_after,
|
||||||
|
refreshing: Arc::new(Mutex::new(std::collections::HashSet::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a value, triggering a background refresh if the entry is a miss.
|
||||||
|
pub async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
|
||||||
|
let result = self.inner.get(key).await?;
|
||||||
|
if result.is_none() {
|
||||||
|
let key_str = key.to_string();
|
||||||
|
let mut refreshing = self.refreshing.lock().await;
|
||||||
|
if refreshing.insert(key_str.clone()) {
|
||||||
|
let inner = self.inner.clone();
|
||||||
|
let refresh_fn = Arc::clone(&self.refresh_fn);
|
||||||
|
let refresh_after = self.refresh_after;
|
||||||
|
let refreshing = self.refreshing.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let refresh_fut = refresh_fn(key_str.clone());
|
||||||
|
match refresh_fut.await {
|
||||||
|
Ok(value) => {
|
||||||
|
let ttl = Some(refresh_after * 2);
|
||||||
|
let _ = inner.set(&key_str, value, ttl).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("background refresh failed for key {}: {}", key_str, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut r = refreshing.lock().await;
|
||||||
|
r.remove(&key_str);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a value in the underlying cache.
|
||||||
|
pub async fn set(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: Vec<u8>,
|
||||||
|
ttl: Option<Duration>,
|
||||||
|
) -> Result<(), CacheError> {
|
||||||
|
self.inner.set(key, value, ttl).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidates a key in the underlying cache.
|
||||||
|
pub async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
||||||
|
self.inner.invalidate(key).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//! Cache-aside with read-through (feature `cache-aside`).
|
||||||
|
//!
|
||||||
|
//! [`CacheAside`] wires a [`Cache`] to a data source: on a miss it invokes a
|
||||||
|
//! user-provided async fetcher, stores the result (with an optional TTL), and
|
||||||
|
//! returns it. This is the standard cache-aside pattern — reads bypass a cold
|
||||||
|
//! cache by falling back to the source of truth.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::traits::{Cache, CacheError};
|
||||||
|
|
||||||
|
/// A generic read-through cache-aside helper.
|
||||||
|
///
|
||||||
|
/// `F` is the data source: an async closure `(owned key) -> Option<Vec<u8>>`.
|
||||||
|
/// The key is passed by value ([`String`]) so the returned future does not
|
||||||
|
/// borrow from the caller, which keeps the API simple and `'static`-friendly.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CacheAside<C, F> {
|
||||||
|
cache: C,
|
||||||
|
fetcher: F,
|
||||||
|
ttl: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C, F, Fut> CacheAside<C, F>
|
||||||
|
where
|
||||||
|
C: Cache,
|
||||||
|
F: Fn(String) -> Fut + Send + Sync,
|
||||||
|
Fut: std::future::Future<Output = Option<Vec<u8>>> + Send,
|
||||||
|
{
|
||||||
|
/// Builds a cache-aside wrapper around `cache` using `fetcher` to fill
|
||||||
|
/// misses. Entries are stored without expiry unless `with_ttl` is used.
|
||||||
|
pub fn new(cache: C, fetcher: F) -> Self {
|
||||||
|
Self {
|
||||||
|
cache,
|
||||||
|
fetcher,
|
||||||
|
ttl: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a `ttl` to every entry written by this wrapper.
|
||||||
|
pub fn with_ttl(mut self, ttl: Duration) -> Self {
|
||||||
|
self.ttl = Some(ttl);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a value for `key`, reading through to the fetcher on a miss and
|
||||||
|
/// caching the result.
|
||||||
|
pub async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
|
||||||
|
if let Some(value) = self.cache.get(key).await? {
|
||||||
|
return Ok(Some(value));
|
||||||
|
}
|
||||||
|
if let Some(value) = (self.fetcher)(key.to_string()).await {
|
||||||
|
self.cache.set(key, value.clone(), self.ttl).await?;
|
||||||
|
Ok(Some(value))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly evicts `key`.
|
||||||
|
pub async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
||||||
|
self.cache.invalidate(key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the underlying cache.
|
||||||
|
pub fn cache(&self) -> &C {
|
||||||
|
&self.cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::memory::MemoryCache;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn fetcher(
|
||||||
|
hits: Arc<AtomicU64>,
|
||||||
|
) -> impl Fn(String) -> std::future::Ready<Option<Vec<u8>>> + Send + Sync {
|
||||||
|
move |_key: String| {
|
||||||
|
let n = hits.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
std::future::ready(Some(format!("fetched-{n}").into_bytes()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn miss_reads_through_and_caches() {
|
||||||
|
let hits = Arc::new(AtomicU64::new(0));
|
||||||
|
let aside = CacheAside::new(MemoryCache::new(), fetcher(hits.clone()));
|
||||||
|
|
||||||
|
let first = aside.get("k").await.unwrap().unwrap();
|
||||||
|
let second = aside.get("k").await.unwrap().unwrap();
|
||||||
|
assert_eq!(first, b"fetched-1");
|
||||||
|
// Cache hit — fetcher not called again.
|
||||||
|
assert_eq!(second, b"fetched-1");
|
||||||
|
assert_eq!(hits.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalidate_forces_refetch() {
|
||||||
|
let hits = Arc::new(AtomicU64::new(0));
|
||||||
|
let aside = CacheAside::new(MemoryCache::new(), fetcher(hits.clone()));
|
||||||
|
let _ = aside.get("k").await.unwrap();
|
||||||
|
aside.invalidate("k").await.unwrap();
|
||||||
|
let again = aside.get("k").await.unwrap().unwrap();
|
||||||
|
assert_eq!(again, b"fetched-2");
|
||||||
|
assert_eq!(hits.load(Ordering::SeqCst), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ttl_applies_to_writes() {
|
||||||
|
let aside = CacheAside::new(MemoryCache::new(), fetcher(Arc::new(AtomicU64::new(0))))
|
||||||
|
.with_ttl(Duration::from_millis(30));
|
||||||
|
let _ = aside.get("k").await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
aside.cache().get("k").await.unwrap(),
|
||||||
|
Some(b"fetched-1".to_vec())
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(60)).await;
|
||||||
|
assert_eq!(aside.cache().get("k").await.unwrap(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! # mytheclipse-cache
|
||||||
|
//!
|
||||||
|
//! A unified multi-layer cache abstraction that keeps your application from
|
||||||
|
//! being locked to any single cache provider.
|
||||||
|
//!
|
||||||
|
//! - **L1 (in-process) caches**: [`memory::MemoryCache`] (zero-dependency,
|
||||||
|
//! default) or [`moka_cache::MokaL1`] (high-performance, TTL/max-capacity).
|
||||||
|
//! - **L2 (distributed) caches**: [`redis::RedisCache`] backed by Redis/Valkey.
|
||||||
|
//! - **Multi-layer composition**: [`multilayer::MultiLayerCache`] layers an L1
|
||||||
|
//! over an L2 behind one [`Cache`] face; reads fall through to L2 and
|
||||||
|
//! backfill L1.
|
||||||
|
//! - **Cache-aside / auto-refresh**: [`cache_aside::CacheAside`] reads through
|
||||||
|
//! to a data source on a miss and caches the result.
|
||||||
|
//!
|
||||||
|
//! The core [`Cache`] trait is byte-oriented; typed convenience (JSON) is
|
||||||
|
//! layered on top via [`memory::typed::TypedCache`].
|
||||||
|
//!
|
||||||
|
//! ## Example
|
||||||
|
//!
|
||||||
|
//! Multi-layer + cache-aside composition (default features):
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! # #[cfg(all(feature = "l1-memory", feature = "cache-aside"))]
|
||||||
|
//! # async fn run() {
|
||||||
|
//! use mytheclipse_cache::{Cache, MemoryCache, MultiLayerCache, CacheAside};
|
||||||
|
//! let l1 = MemoryCache::new();
|
||||||
|
//! let l2 = MemoryCache::new(); // in a real app: a RedisCache
|
||||||
|
//! let cache = MultiLayerCache::new(l1, l2);
|
||||||
|
//!
|
||||||
|
//! cache.set("user:1", b"payload".to_vec(), None).await.unwrap();
|
||||||
|
//! assert_eq!(cache.get("user:1").await.unwrap(), Some(b"payload".to_vec()));
|
||||||
|
//!
|
||||||
|
//! // Cache-aside: fill misses from a source of truth.
|
||||||
|
//! let aside = CacheAside::new(
|
||||||
|
//! MemoryCache::new(),
|
||||||
|
//! |key| async move { Some(format!("data-for-{key}").into_bytes()) },
|
||||||
|
//! );
|
||||||
|
//! let _v = aside.get("orders:42").await.unwrap();
|
||||||
|
//! # }
|
||||||
|
//! # #[cfg(not(all(feature = "l1-memory", feature = "cache-aside")))]
|
||||||
|
//! # fn run() {}
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod traits;
|
||||||
|
|
||||||
|
#[cfg(feature = "l1-memory")]
|
||||||
|
pub mod memory;
|
||||||
|
|
||||||
|
#[cfg(feature = "l1-moka")]
|
||||||
|
pub mod moka_cache;
|
||||||
|
|
||||||
|
#[cfg(feature = "l2-redis")]
|
||||||
|
pub mod redis;
|
||||||
|
|
||||||
|
#[cfg(feature = "cache-aside")]
|
||||||
|
pub mod cache_aside;
|
||||||
|
|
||||||
|
#[cfg(feature = "cache-aside")]
|
||||||
|
pub mod multilayer;
|
||||||
|
|
||||||
|
#[cfg(feature = "cache-aside")]
|
||||||
|
pub mod auto_refresh;
|
||||||
|
|
||||||
|
#[cfg(feature = "cache-aside")]
|
||||||
|
pub mod metrics;
|
||||||
|
|
||||||
|
pub use traits::{Cache, CacheError};
|
||||||
|
|
||||||
|
#[cfg(feature = "l1-memory")]
|
||||||
|
pub use memory::MemoryCache;
|
||||||
|
|
||||||
|
#[cfg(feature = "l1-moka")]
|
||||||
|
pub use moka_cache::MokaL1;
|
||||||
|
|
||||||
|
#[cfg(feature = "l2-redis")]
|
||||||
|
pub use redis::RedisCache;
|
||||||
|
|
||||||
|
#[cfg(feature = "cache-aside")]
|
||||||
|
pub use cache_aside::CacheAside;
|
||||||
|
|
||||||
|
#[cfg(feature = "cache-aside")]
|
||||||
|
pub use multilayer::MultiLayerCache;
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
//! A simple, dependency-free in-process cache (L1, `l1-memory`).
|
||||||
|
//!
|
||||||
|
//! Backed by a `HashMap<String, (Vec<u8>, Instant)>` guarded by a `Mutex`.
|
||||||
|
//! Entries are lazily expired on access by comparing against `Instant`; a
|
||||||
|
//! monotonic clock keeps TTLs robust against wall-clock discontinuities.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::traits::{Cache, CacheError};
|
||||||
|
|
||||||
|
/// A wrapping entry: `None` expiry means the value never expires.
|
||||||
|
type Entry = (Vec<u8>, Option<Instant>);
|
||||||
|
|
||||||
|
/// An in-process [`Cache`] for L1 caching.
|
||||||
|
///
|
||||||
|
/// Default instance is **unbounded** — it grows until the process runs out of
|
||||||
|
/// memory. For memory-constrained workloads, use [`MemoryCache::with_max_entries`]
|
||||||
|
/// to install a simple LRU-style cap: when the cap is exceeded, the oldest
|
||||||
|
/// (least-recently-inserted) entry is evicted.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MemoryCache {
|
||||||
|
inner: Arc<Mutex<HashMap<String, Entry>>>,
|
||||||
|
/// When `Some(n)`, the cache refuses more than `n` live entries and evicts
|
||||||
|
/// the oldest on overflow. `None` = unbounded (legacy default).
|
||||||
|
max_entries: Option<usize>,
|
||||||
|
/// Insertion order, for eviction when `max_entries` is set.
|
||||||
|
order: Arc<Mutex<VecDeque<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MemoryCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
max_entries: None,
|
||||||
|
order: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryCache {
|
||||||
|
/// Builds an empty in-memory cache (unbounded by default).
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-allocates space for `capacity` entries to reduce reallocation.
|
||||||
|
pub fn with_capacity(self, capacity: usize) -> Self {
|
||||||
|
self.inner.lock().unwrap().reserve(capacity);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs a bounded LRU-style cap. When the cache exceeds `max`, the
|
||||||
|
/// oldest (least-recently-inserted) entry is evicted on each `set`.
|
||||||
|
///
|
||||||
|
/// This is the recommended constructor for production L1 caches: a
|
||||||
|
/// [`MemoryCache::new()`] (unbounded) left unmanaged can grow without bound
|
||||||
|
/// and exhaust process memory.
|
||||||
|
pub fn with_max_entries(mut self, max: usize) -> Self {
|
||||||
|
assert!(max > 0, "mytheclipse-cache: with_max_entries must be > 0");
|
||||||
|
self.max_entries = Some(max);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configured max entries, if any.
|
||||||
|
pub fn max_entries(&self) -> Option<usize> {
|
||||||
|
self.max_entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Cache for MemoryCache {
|
||||||
|
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
|
||||||
|
let mut map = self.inner.lock().unwrap();
|
||||||
|
match map.get(key) {
|
||||||
|
Some((value, Some(expires))) if *expires <= Instant::now() => {
|
||||||
|
map.remove(key);
|
||||||
|
self.remove_order(key);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Some((value, _)) => Ok(Some(value.clone())),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: Vec<u8>,
|
||||||
|
ttl: Option<Duration>,
|
||||||
|
) -> Result<(), CacheError> {
|
||||||
|
let expires = ttl.map(|d| Instant::now() + d);
|
||||||
|
let mut map = self.inner.lock().unwrap();
|
||||||
|
let is_new = !map.contains_key(key);
|
||||||
|
map.insert(key.to_string(), (value, expires));
|
||||||
|
if is_new {
|
||||||
|
let mut order = self.order.lock().unwrap();
|
||||||
|
order.push_back(key.to_string());
|
||||||
|
if let Some(cap) = self.max_entries {
|
||||||
|
while order.len() > cap {
|
||||||
|
if let Some(oldest) = order.pop_front() {
|
||||||
|
map.remove(&oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
||||||
|
self.inner.lock().unwrap().remove(key);
|
||||||
|
self.remove_order(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear(&self) -> Result<(), CacheError> {
|
||||||
|
self.inner.lock().unwrap().clear();
|
||||||
|
self.order.lock().unwrap().clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryCache {
|
||||||
|
/// Removes `key` from the insertion-order deque (if present).
|
||||||
|
fn remove_order(&self, key: &str) {
|
||||||
|
let mut order = self.order.lock().unwrap();
|
||||||
|
order.retain(|k| k != key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A typed view over a byte cache using `serde`-compatible (JSON) encoding.
|
||||||
|
///
|
||||||
|
/// Only enabled with the `cache-aside` feature, which pulls in `serde`.
|
||||||
|
#[cfg(feature = "cache-aside")]
|
||||||
|
pub mod typed {
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Wraps a [`Cache`] with JSON-based typed get/set.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TypedCache<C> {
|
||||||
|
inner: C,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: Cache> TypedCache<C> {
|
||||||
|
/// Wraps `inner`.
|
||||||
|
pub fn new(inner: C) -> Self {
|
||||||
|
Self { inner }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches and deserializes a value.
|
||||||
|
pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, CacheError> {
|
||||||
|
match self.inner.get(key).await? {
|
||||||
|
Some(bytes) => serde_json::from_slice(&bytes)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| CacheError::Serialization(e.to_string())),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes and stores a value.
|
||||||
|
pub async fn set<T: Serialize>(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: &T,
|
||||||
|
ttl: Option<Duration>,
|
||||||
|
) -> Result<(), CacheError> {
|
||||||
|
let bytes =
|
||||||
|
serde_json::to_vec(value).map_err(|e| CacheError::Serialization(e.to_string()))?;
|
||||||
|
self.inner.set(key, bytes, ttl).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the underlying byte cache.
|
||||||
|
pub fn into_inner(self) -> C {
|
||||||
|
self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_get_roundtrip() {
|
||||||
|
let c = MemoryCache::new();
|
||||||
|
c.set("k", b"v".to_vec(), None).await.unwrap();
|
||||||
|
assert_eq!(c.get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||||
|
assert_eq!(c.get("missing").await.unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ttl_expires_entry() {
|
||||||
|
let c = MemoryCache::new();
|
||||||
|
c.set("k", b"v".to_vec(), Some(Duration::from_millis(30)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(c.get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||||
|
tokio::time::sleep(Duration::from_millis(60)).await;
|
||||||
|
assert_eq!(c.get("k").await.unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalidate_and_clear() {
|
||||||
|
let c = MemoryCache::new();
|
||||||
|
c.set("a", b"1".to_vec(), None).await.unwrap();
|
||||||
|
c.set("b", b"2".to_vec(), None).await.unwrap();
|
||||||
|
c.invalidate("a").await.unwrap();
|
||||||
|
assert_eq!(c.get("a").await.unwrap(), None);
|
||||||
|
assert_eq!(c.get("b").await.unwrap(), Some(b"2".to_vec()));
|
||||||
|
c.clear().await.unwrap();
|
||||||
|
assert_eq!(c.get("b").await.unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asserts that an unbounded `MemoryCache::with_max_entries(0)` panics,
|
||||||
|
/// preventing a no-op cache that accepts zero entries.
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "must be > 0")]
|
||||||
|
fn zero_max_panics() {
|
||||||
|
let _ = MemoryCache::new().with_max_entries(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bounded_cache_evicts_oldest() {
|
||||||
|
let c = MemoryCache::new().with_max_entries(2);
|
||||||
|
c.set("a", b"1".to_vec(), None).await.unwrap();
|
||||||
|
c.set("b", b"2".to_vec(), None).await.unwrap();
|
||||||
|
c.set("c", b"3".to_vec(), None).await.unwrap();
|
||||||
|
// "a" (oldest) should have been evicted.
|
||||||
|
assert_eq!(c.get("a").await.unwrap(), None);
|
||||||
|
assert_eq!(c.get("b").await.unwrap(), Some(b"2".to_vec()));
|
||||||
|
assert_eq!(c.get("c").await.unwrap(), Some(b"3".to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "cache-aside")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn typed_cache_roundtrip() {
|
||||||
|
use typed::TypedCache;
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
|
||||||
|
struct User {
|
||||||
|
id: u64,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
let typed = TypedCache::new(MemoryCache::new());
|
||||||
|
typed
|
||||||
|
.set(
|
||||||
|
"u",
|
||||||
|
&User {
|
||||||
|
id: 1,
|
||||||
|
name: "alice".into(),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let got: User = typed.get("u").await.unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
User {
|
||||||
|
id: 1,
|
||||||
|
name: "alice".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! Cache instrumentation metrics (hit/miss/eviction counters).
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
/// Tracks cache hit, miss, eviction, and error counts.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct CacheMetrics {
|
||||||
|
hits: AtomicU64,
|
||||||
|
misses: AtomicU64,
|
||||||
|
evictions: AtomicU64,
|
||||||
|
errors: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheMetrics {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hit(&self) {
|
||||||
|
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn miss(&self) {
|
||||||
|
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn eviction(&self) {
|
||||||
|
self.evictions.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(&self) {
|
||||||
|
self.errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> CacheSnapshot {
|
||||||
|
CacheSnapshot {
|
||||||
|
hits: self.hits.load(Ordering::Relaxed),
|
||||||
|
misses: self.misses.load(Ordering::Relaxed),
|
||||||
|
evictions: self.evictions.load(Ordering::Relaxed),
|
||||||
|
errors: self.errors.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A point-in-time read of cache metrics.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CacheSnapshot {
|
||||||
|
pub hits: u64,
|
||||||
|
pub misses: u64,
|
||||||
|
pub evictions: u64,
|
||||||
|
pub errors: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheSnapshot {
|
||||||
|
pub fn hit_rate(&self) -> f64 {
|
||||||
|
let total = self.hits + self.misses;
|
||||||
|
if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.hits as f64 / total as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
//! A high-performance in-process cache backed by Moka (L1, `l1-moka`).
|
||||||
|
//!
|
||||||
|
//! Moka provides automatic max-capacity and (optionally) TTL-based eviction,
|
||||||
|
//! so this L1 is well-suited to workloads where memory bounds matter.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use moka::future::Cache as MokaCache;
|
||||||
|
|
||||||
|
use crate::traits::{Cache, CacheError};
|
||||||
|
|
||||||
|
/// A Moka-backed [`Cache`] for L1 caching.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MokaL1 {
|
||||||
|
inner: MokaCache<String, Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MokaL1 {
|
||||||
|
/// Builds a Moka cache with `max_capacity` entries and an optional default
|
||||||
|
/// `ttl`.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `max_capacity` is `0`. In Moka, a `max_capacity` of `0` is a
|
||||||
|
/// sentinel for **zero-entries-allowed** — every `insert` is silently
|
||||||
|
/// dropped — which is almost certainly a caller mistake (the natural way to
|
||||||
|
/// express "unbounded" in other caches). Pass `1..=u64::MAX`; use
|
||||||
|
/// [`MemoryCache`](crate::memory::MemoryCache) if you truly need an
|
||||||
|
/// unbounded in-process cache.
|
||||||
|
pub fn new(max_capacity: u64, ttl: Option<Duration>) -> Self {
|
||||||
|
assert!(
|
||||||
|
max_capacity > 0,
|
||||||
|
"mytheclipse-cache: MokaL1::new(max_capacity) must be > 0; \
|
||||||
|
moka treats 0 as a permanent no-insert sentinel. \
|
||||||
|
Use MemoryCache for an unbounded cache."
|
||||||
|
);
|
||||||
|
let mut builder = MokaCache::builder().max_capacity(max_capacity);
|
||||||
|
if let Some(ttl) = ttl {
|
||||||
|
builder = builder.time_to_live(ttl);
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
inner: builder.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Cache for MokaL1 {
|
||||||
|
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
|
||||||
|
Ok(self.inner.get(key).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts `value`, using the cache's configured TTL policy. The per-call
|
||||||
|
/// `ttl` argument is intentionally ignored — Moka applies a single TTL
|
||||||
|
/// configured on the builder, and per-entry overrides are not exposed here.
|
||||||
|
async fn set(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: Vec<u8>,
|
||||||
|
_ttl: Option<Duration>,
|
||||||
|
) -> Result<(), CacheError> {
|
||||||
|
self.inner.insert(key.to_string(), value).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
||||||
|
self.inner.invalidate(key).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear(&self) -> Result<(), CacheError> {
|
||||||
|
self.inner.invalidate_all();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_get_roundtrip() {
|
||||||
|
let c = MokaL1::new(100, None);
|
||||||
|
c.set("k", b"v".to_vec(), None).await.unwrap();
|
||||||
|
assert_eq!(c.get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||||
|
assert_eq!(c.get("missing").await.unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalidate_and_clear() {
|
||||||
|
let c = MokaL1::new(100, None);
|
||||||
|
c.set("a", b"1".to_vec(), None).await.unwrap();
|
||||||
|
c.set("b", b"2".to_vec(), None).await.unwrap();
|
||||||
|
c.invalidate("a").await.unwrap();
|
||||||
|
assert_eq!(c.get("a").await.unwrap(), None);
|
||||||
|
assert_eq!(c.get("b").await.unwrap(), Some(b"2".to_vec()));
|
||||||
|
c.clear().await.unwrap();
|
||||||
|
assert_eq!(c.get("b").await.unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asserts that `max_capacity == 0` panics with a clear message, rather
|
||||||
|
/// than silently creating a cache that never accepts entries.
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "must be > 0")]
|
||||||
|
fn zero_capacity_panics() {
|
||||||
|
let _ = MokaL1::new(0, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ttl_does_expire() {
|
||||||
|
// Keep a firm TTL assertion; sleep well past the expiry window.
|
||||||
|
let c = MokaL1::new(100, Some(Duration::from_millis(40)));
|
||||||
|
c.set("k", b"v".to_vec(), None).await.unwrap();
|
||||||
|
assert_eq!(c.get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||||
|
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||||
|
let v = c.get("k").await.unwrap();
|
||||||
|
assert!(matches!(v, None));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! Multi-layer (L1/L2) caching behind a single [`Cache`] face.
|
||||||
|
//!
|
||||||
|
//! [`MultiLayerCache`] layers a fast in-process L1 over a slower but larger
|
||||||
|
//! L2 (e.g. Redis). Reads are L1-first with an L2 fallback; a hit on L2 is
|
||||||
|
//! backfilled into L1. Writes and invalidations go to both layers.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::traits::{Cache, CacheError};
|
||||||
|
|
||||||
|
/// A read-through, write-through composition of an L1 and L2 cache.
|
||||||
|
///
|
||||||
|
/// `L1` is typically [`crate::memory::MemoryCache`] or
|
||||||
|
/// [`crate::moka_cache::MokaL1`]; `L2` is typically a distributed cache such
|
||||||
|
/// as a Redis backend. Order of layers fixed: `L1` is consulted first.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MultiLayerCache<L1, L2> {
|
||||||
|
l1: L1,
|
||||||
|
l2: L2,
|
||||||
|
/// When `true`, an L2 hit is written back into L1 (default `true`).
|
||||||
|
populate_l1: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<L1, L2> MultiLayerCache<L1, L2>
|
||||||
|
where
|
||||||
|
L1: Cache,
|
||||||
|
L2: Cache,
|
||||||
|
{
|
||||||
|
/// Builds a two-layer cache with L1-backfill enabled.
|
||||||
|
pub fn new(l1: L1, l2: L2) -> Self {
|
||||||
|
Self {
|
||||||
|
l1,
|
||||||
|
l2,
|
||||||
|
populate_l1: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disables L1 backfill-on-read.
|
||||||
|
pub fn without_l1_backfill(mut self) -> Self {
|
||||||
|
self.populate_l1 = false;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the L1 layer.
|
||||||
|
pub fn l1(&self) -> &L1 {
|
||||||
|
&self.l1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the L2 layer.
|
||||||
|
pub fn l2(&self) -> &L2 {
|
||||||
|
&self.l2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<L1, L2> Cache for MultiLayerCache<L1, L2>
|
||||||
|
where
|
||||||
|
L1: Cache,
|
||||||
|
L2: Cache,
|
||||||
|
{
|
||||||
|
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
|
||||||
|
// L1 first.
|
||||||
|
if let Some(value) = self.l1.get(key).await? {
|
||||||
|
return Ok(Some(value));
|
||||||
|
}
|
||||||
|
// L2 fallback.
|
||||||
|
if let Some(value) = self.l2.get(key).await? {
|
||||||
|
if self.populate_l1 {
|
||||||
|
self.l1.set(key, value.clone(), None).await?;
|
||||||
|
}
|
||||||
|
return Ok(Some(value));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: Vec<u8>,
|
||||||
|
ttl: Option<Duration>,
|
||||||
|
) -> Result<(), CacheError> {
|
||||||
|
self.l1.set(key, value.clone(), ttl).await?;
|
||||||
|
self.l2.set(key, value, ttl).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
||||||
|
self.l1.invalidate(key).await?;
|
||||||
|
self.l2.invalidate(key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear(&self) -> Result<(), CacheError> {
|
||||||
|
self.l1.clear().await?;
|
||||||
|
self.l2.clear().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::memory::MemoryCache;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_through_populates_l1() {
|
||||||
|
let l2 = MemoryCache::new();
|
||||||
|
l2.set("k", b"l2-value".to_vec(), None).await.unwrap();
|
||||||
|
|
||||||
|
let layered = MultiLayerCache::new(MemoryCache::new(), l2);
|
||||||
|
assert_eq!(layered.l1().get("k").await.unwrap(), None);
|
||||||
|
assert_eq!(layered.get("k").await.unwrap(), Some(b"l2-value".to_vec()));
|
||||||
|
// L2 hit should have populated L1.
|
||||||
|
assert_eq!(
|
||||||
|
layered.l1().get("k").await.unwrap(),
|
||||||
|
Some(b"l2-value".to_vec())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_goes_to_both() {
|
||||||
|
let l1 = MemoryCache::new();
|
||||||
|
let l2 = MemoryCache::new();
|
||||||
|
let layered = MultiLayerCache::new(l1, l2.clone());
|
||||||
|
layered.set("k", b"v".to_vec(), None).await.unwrap();
|
||||||
|
assert_eq!(layered.l1().get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||||
|
assert_eq!(l2.get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalidate_clears_both() {
|
||||||
|
let l1 = MemoryCache::new();
|
||||||
|
let l2 = MemoryCache::new();
|
||||||
|
let layered = MultiLayerCache::new(l1, l2);
|
||||||
|
layered.set("k", b"v".to_vec(), None).await.unwrap();
|
||||||
|
layered.invalidate("k").await.unwrap();
|
||||||
|
assert_eq!(layered.l1().get("k").await.unwrap(), None);
|
||||||
|
assert_eq!(layered.l2().get("k").await.unwrap(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! A distributed (L2) cache backed by Redis / Valkey (feature `l2-redis`).
|
||||||
|
//!
|
||||||
|
//! Wraps a `redis` async connection (multiplexed). Values are stored as raw
|
||||||
|
//! Redis strings with an optional TTL (`SETEX` when a TTL is given). The
|
||||||
|
//! caller provides the connection; this type only issues cache commands.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use redis::aio::MultiplexedConnection;
|
||||||
|
use redis::{AsyncCommands, RedisError};
|
||||||
|
|
||||||
|
use crate::traits::{Cache, CacheError};
|
||||||
|
|
||||||
|
/// Map a Redis error onto a [`CacheError`].
|
||||||
|
fn map_err(e: RedisError) -> CacheError {
|
||||||
|
CacheError::Io(e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An L2 cache backed by a `redis` [`MultiplexedConnection`].
|
||||||
|
///
|
||||||
|
/// The connection is supplied by the caller; it is cheaply cloned (the
|
||||||
|
/// multiplexed connection is `Arc`-backed internally), so one pool can drive
|
||||||
|
/// both cache operations and other Redis usage.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RedisCache {
|
||||||
|
conn: MultiplexedConnection,
|
||||||
|
/// Optional namespace prefix prepended to every key.
|
||||||
|
prefix: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedisCache {
|
||||||
|
/// Wraps an existing connection.
|
||||||
|
pub fn new(conn: MultiplexedConnection) -> Self {
|
||||||
|
Self::with_prefix(conn, String::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps a connection and adds a namespace prefix to every key.
|
||||||
|
pub fn with_prefix(conn: MultiplexedConnection, prefix: String) -> Self {
|
||||||
|
Self { conn, prefix }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(&self, key: &str) -> String {
|
||||||
|
if self.prefix.is_empty() {
|
||||||
|
key.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}{}", self.prefix, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Cache for RedisCache {
|
||||||
|
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
|
||||||
|
// `get::<_, Option<Vec<u8>>>` returns `None` for a missing key.
|
||||||
|
let mut c = self.conn.clone();
|
||||||
|
let k = self.key(key);
|
||||||
|
let result: Result<Option<Vec<u8>>, RedisError> = c.get(&k).await;
|
||||||
|
result.map_err(map_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: Vec<u8>,
|
||||||
|
ttl: Option<Duration>,
|
||||||
|
) -> Result<(), CacheError> {
|
||||||
|
let mut c = self.conn.clone();
|
||||||
|
let k = self.key(key);
|
||||||
|
match ttl {
|
||||||
|
Some(ttl) => {
|
||||||
|
// Use millisecond precision (PSETEX) so sub-second TTLs are
|
||||||
|
// honored faithfully. Previously `set_ex(seconds.max(1))`
|
||||||
|
// rounded anything < 1s up to 1s, silently changing expiry
|
||||||
|
// semantics for short-lived cache entries.
|
||||||
|
let ms = ttl.as_millis();
|
||||||
|
if ms == 0 {
|
||||||
|
return Err(CacheError::Key(
|
||||||
|
"ttl of 0ms not allowed — pass None to store permanently".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let ms = ms as u64;
|
||||||
|
let result: Result<(), RedisError> = c.pset_ex(&k, value, ms).await;
|
||||||
|
result.map_err(map_err)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let result: Result<(), RedisError> = c.set(&k, value).await;
|
||||||
|
result.map_err(map_err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
||||||
|
let mut c = self.conn.clone();
|
||||||
|
let k = self.key(key);
|
||||||
|
let result: Result<u64, RedisError> = c.del(&k).await;
|
||||||
|
result.map(|_| ()).map_err(map_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear(&self) -> Result<(), CacheError> {
|
||||||
|
// Deliberately does nothing: a blind `FLUSHDB`/`FLUSHALL` on a shared
|
||||||
|
// Redis instance would destroy keys owned by other consumers.
|
||||||
|
// Consumers that need a true wipe must either (a) use a dedicated Redis
|
||||||
|
// DB / namespace prefix they own exclusively, or (b) call
|
||||||
|
// `invalidate` per-key for the keys they manage.
|
||||||
|
//
|
||||||
|
// See: https://redis.io/commands/flushdb/ (no key-scoping)
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Integration test requiring a live Redis at `REDIS_URL`
|
||||||
|
/// (e.g. `redis://127.0.0.1:6379`). Run with:
|
||||||
|
/// `REDIS_URL=redis://127.0.0.1:6379 cargo test -p mytheclipse-cache --features l2-redis -- --ignored` .
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires a live Redis instance (REDIS_URL)"]
|
||||||
|
async fn set_get_roundtrip_live() {
|
||||||
|
let url = std::env::var("REDIS_URL").expect("set REDIS_URL");
|
||||||
|
let client = redis::Client::open(url).expect("valid redis url");
|
||||||
|
let conn = client
|
||||||
|
.get_multiplexed_tokio_connection()
|
||||||
|
.await
|
||||||
|
.expect("connect");
|
||||||
|
let cache = RedisCache::with_prefix(conn, "mytheclipse_cache_test:".to_string());
|
||||||
|
|
||||||
|
cache
|
||||||
|
.set("k", b"v".to_vec(), Some(Duration::from_secs(3600)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cache.get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||||
|
cache.invalidate("k").await.unwrap();
|
||||||
|
assert_eq!(cache.get("k").await.unwrap(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//! The core [`Cache`] and [`KeyEncoder`] traits.
|
||||||
|
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
/// Errors returned by cache operations.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CacheError {
|
||||||
|
/// The backend could not be reached (e.g. Redis connection lost).
|
||||||
|
Io(String),
|
||||||
|
/// A value could not be serialized / deserialized.
|
||||||
|
Serialization(String),
|
||||||
|
/// A key could not be encoded for the backend.
|
||||||
|
Key(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CacheError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(s) => write!(f, "cache io: {s}"),
|
||||||
|
Self::Serialization(s) => write!(f, "cache serialization: {s}"),
|
||||||
|
Self::Key(s) => write!(f, "cache key: {s}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CacheError {}
|
||||||
|
|
||||||
|
/// A generic byte-oriented cache.
|
||||||
|
///
|
||||||
|
/// Real caches operate on bytes or strings; typed convenience is layered on
|
||||||
|
/// top (see [`crate::memory::typed::TypedCache`], behind `cache-aside`).
|
||||||
|
/// Implementors control the value format.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Cache: Send + Sync {
|
||||||
|
/// Fetches a value by key. `None` indicates a miss.
|
||||||
|
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError>;
|
||||||
|
/// Stores a value under `key`, optionally expiring after `ttl`.
|
||||||
|
async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>)
|
||||||
|
-> Result<(), CacheError>;
|
||||||
|
/// Removes a key.
|
||||||
|
async fn invalidate(&self, key: &str) -> Result<(), CacheError>;
|
||||||
|
/// Removes all entries.
|
||||||
|
async fn clear(&self) -> Result<(), CacheError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keys given to the byte-oriented [`Cache`] are `&str`, but concrete backends
|
||||||
|
/// may need richer keys. [`KeyEncoder`] turns typed keys into canonical strings.
|
||||||
|
pub trait KeyEncoder {
|
||||||
|
/// The "shape" of a key, e.g. `"user:{id}:profile"`.
|
||||||
|
fn encode<C: Into<Cow<'static, str>>, R: std::fmt::Display>(parts: (C, R)) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A blanket implementation that formats `{collection}:{id}`.
|
||||||
|
pub struct DefaultKeyEncoder;
|
||||||
|
|
||||||
|
impl KeyEncoder for DefaultKeyEncoder {
|
||||||
|
fn encode<C: Into<Cow<'static, str>>, R: std::fmt::Display>(parts: (C, R)) -> String {
|
||||||
|
format!("{}:{}", parts.0.into(), parts.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_key_encoder_formats() {
|
||||||
|
assert_eq!(DefaultKeyEncoder::encode(("user", 42)), "user:42");
|
||||||
|
assert_eq!(
|
||||||
|
DefaultKeyEncoder::encode(("session", "abc-123")),
|
||||||
|
"session:abc-123"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "mytheclipse-cli"
|
||||||
|
version = "1.21.2"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.75"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/asepharyana/mytheclipse"
|
||||||
|
homepage = "https://github.com/asepharyana/mytheclipse"
|
||||||
|
documentation = "https://docs.rs/mytheclipse-cli"
|
||||||
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
|
description = "CLI framework with built-in serve, worker, and migrate subcommands for mytheclipse applications."
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["cli", "clap", "command-line", "framework"]
|
||||||
|
categories = ["command-line-utilities", "development-tools"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["clap-derive"]
|
||||||
|
# Use clap derive macros.
|
||||||
|
clap-derive = ["dep:clap"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tracing = "0.1"
|
||||||
|
clap = { version = "4", features = ["derive"], optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.53", features = ["full"] }
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2026 The corex Authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 The corex Authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# mytheclipse-cli
|
||||||
|
|
||||||
|
CLI framework for mytheclipse applications with built-in subcommands:
|
||||||
|
`serve`, `worker`, `migrate`, `health`, and `version`.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Feature | Default | Description |
|
||||||
|
| :--- | :---: | :--- |
|
||||||
|
| `clap-derive` | yes | Clap derive macros for argument parsing. |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
mytheclipse-cli = "0.2"
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use mytheclipse_cli::CliApp;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let app = CliApp::parse();
|
||||||
|
match app.command {
|
||||||
|
Subcommand::Serve => { /* ... */ }
|
||||||
|
Subcommand::Worker { topics } => { /* ... */ }
|
||||||
|
Subcommand::Migrate => { /* ... */ }
|
||||||
|
Subcommand::Health => { /* ... */ }
|
||||||
|
Subcommand::Version => { println!("1.0.0"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! Clap-based CLI builder implementation.
|
||||||
|
|
||||||
|
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand as ClapSubcommand};
|
||||||
|
|
||||||
|
/// A mytheclipse CLI application.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "myapp", version, about)]
|
||||||
|
pub struct CliApp {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Subcommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built-in subcommands for mytheclipse applications.
|
||||||
|
#[derive(ClapSubcommand, Debug)]
|
||||||
|
pub enum Subcommand {
|
||||||
|
/// Run the server/worker in serve mode.
|
||||||
|
Serve,
|
||||||
|
/// Run background job workers.
|
||||||
|
Worker {
|
||||||
|
/// Topic(s) to consume from.
|
||||||
|
topics: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Run database migrations.
|
||||||
|
Migrate,
|
||||||
|
/// Check service health.
|
||||||
|
Health,
|
||||||
|
/// Print version information.
|
||||||
|
Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder for CliApp with configuration.
|
||||||
|
pub struct CliBuilder {
|
||||||
|
name: String,
|
||||||
|
about: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CliBuilder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "myapp".to_string(),
|
||||||
|
about: "A mytheclipse application".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliBuilder {
|
||||||
|
pub fn new(name: impl Into<String>, about: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
about: about.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> CliApp {
|
||||||
|
// Apply the configured name/about to the derived clap Command so the
|
||||||
|
// builder's fields are honored in the rendered help/usage.
|
||||||
|
let Self { name, about } = self;
|
||||||
|
// clap's `Str`/`StyledStr` only accept 'static references, so leak
|
||||||
|
// the owned strings (build(self) consumes self once, so a single,
|
||||||
|
// process-lifetime leak is acceptable).
|
||||||
|
let name: &'static str = String::leak(name);
|
||||||
|
let about: &'static str = String::leak(about);
|
||||||
|
let cmd = <CliApp as CommandFactory>::command()
|
||||||
|
.name(name)
|
||||||
|
.about(about);
|
||||||
|
CliApp::from_arg_matches(&cmd.get_matches()).unwrap_or_else(|e| e.exit())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//! # mytheclipse-cli
|
||||||
|
//!
|
||||||
|
//! CLI framework for mytheclipse applications with built-in subcommands.
|
||||||
|
//!
|
||||||
|
//! ## Quick Start
|
||||||
|
//!
|
||||||
|
//! ```toml
|
||||||
|
//! [dependencies]
|
||||||
|
//! mytheclipse-cli = "0.2"
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#[cfg(feature = "clap-derive")]
|
||||||
|
pub mod builder;
|
||||||
|
|
||||||
|
#[cfg(feature = "clap-derive")]
|
||||||
|
pub use builder::{CliApp, CliBuilder, Subcommand};
|
||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user