fix: bound fleet rebuilds and enable OTLP exporter retries
This commit is contained in:
10 files changed
+252
-45
No files matched your search
Generated
+40
-13
@@ -1743,9 +1743,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry"
|
name = "opentelemetry"
|
||||||
version = "0.31.0"
|
version = "0.33.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
|
checksum = "8c1f1a6ac9c875a4941e4c7c085b61e8f4daa326de03b4701768c3491a6ad9ee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
@@ -1757,22 +1757,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-appender-tracing"
|
name = "opentelemetry-appender-tracing"
|
||||||
version = "0.31.1"
|
version = "0.33.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2"
|
checksum = "1f01f1c53c46ce80e5fd8cb7a646f13766fd8f315a8a5b75b9bad59a08d2c5c7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-opentelemetry",
|
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-otlp"
|
name = "opentelemetry-otlp"
|
||||||
version = "0.31.1"
|
version = "0.33.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f"
|
checksum = "0756af24841e367128ef31c831999937b8b5c5197d47914590d8f4e2e88c2e1a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"http",
|
"http",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
@@ -1782,13 +1781,14 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tonic",
|
"tonic",
|
||||||
|
"tonic-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-proto"
|
name = "opentelemetry-proto"
|
||||||
version = "0.31.0"
|
version = "0.33.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
|
checksum = "d576f5dddc3299341bcd85a04be17b2564844c0c9563214f02d85f2524d1afae"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
@@ -1799,15 +1799,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry_sdk"
|
name = "opentelemetry_sdk"
|
||||||
version = "0.31.0"
|
version = "0.33.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
|
checksum = "372a912db2974d1990207006fd0e3aa0ef3fd4ad3be43c65cfaadf2206866061"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-executor",
|
"futures-executor",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
"portable-atomic",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
@@ -1927,6 +1928,12 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1993,6 +2000,15 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost-types"
|
||||||
|
version = "0.14.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
|
||||||
|
dependencies = [
|
||||||
|
"prost",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pty-process"
|
name = "pty-process"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -3268,6 +3284,17 @@ dependencies = [
|
|||||||
"tonic",
|
"tonic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tonic-types"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a875a902255423d34c1f20838ab374126db8eb41625b7947a1d54113b0b7399"
|
||||||
|
dependencies = [
|
||||||
|
"prost",
|
||||||
|
"prost-types",
|
||||||
|
"tonic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -3383,9 +3410,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-opentelemetry"
|
name = "tracing-opentelemetry"
|
||||||
version = "0.32.1"
|
version = "0.34.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc"
|
checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
|
|||||||
+4
-3
@@ -17,9 +17,10 @@ and reuse it. The helper checks the key again after building: if inputs changed
|
|||||||
while database reads/grouping were in flight, it discards the result and retries.
|
while database reads/grouping were in flight, it discards the result and retries.
|
||||||
Build errors are returned, not cached; cancelling a reader releases the lock.
|
Build errors are returned, not cached; cancelling a reader releases the lock.
|
||||||
The previous view is not served as a fallback after a rebuild error.
|
The previous view is not served as a fallback after a rebuild error.
|
||||||
Retries currently have no fixed limit. Sustained mutations faster than rebuilding
|
After three invalidated builds, the reader stops and releases the lock. Fleet
|
||||||
can delay the rebuilding reader and readers waiting on its lock; bounding this
|
list/wake requests return HTTP 503 with code `fleet_busy`; clients can retry
|
||||||
behavior is tracked as a reliability follow-up.
|
later. No stale view or command is used as a fallback. This bounds the number
|
||||||
|
of rebuilds per reader, not database execution time or total concurrent requests.
|
||||||
|
|
||||||
Wake uses the same view, but command dispatch still checks the current session;
|
Wake uses the same view, but command dispatch still checks the current session;
|
||||||
a cached route cannot grant access to a disconnected agent.
|
a cached route cannot grant access to a disconnected agent.
|
||||||
|
|||||||
+2
-1
@@ -41,6 +41,7 @@ of remembered identifiers based on observation expiry.
|
|||||||
- [x] Lazy grouped fleet-view cache with store/connection invalidation and stale-build rejection. See [fleet cache](fleet-cache.md).
|
- [x] Lazy grouped fleet-view cache with store/connection invalidation and stale-build rejection. See [fleet cache](fleet-cache.md).
|
||||||
|
|
||||||
- [x] Separate console, OTLP log, and trace filters using per-layer filtering. Explicit signal config overrides `RUST_LOG`, then CLI verbosity supplies defaults. Unconfigured defaults remain info-level; exporter-failure logs stay local. Live validation pending.
|
- [x] Separate console, OTLP log, and trace filters using per-layer filtering. Explicit signal config overrides `RUST_LOG`, then CLI verbosity supplies defaults. Unconfigured defaults remain info-level; exporter-failure logs stay local. Live validation pending.
|
||||||
|
- [x] Upgrade the matched OpenTelemetry stack to 0.33 with exporter-managed backoff/jitter retries. Controlled collector tests cover all-signal recovery, bounded retries, and no retry on authentication rejection; live outage validation remains pending.
|
||||||
- [ ] Record hostname-resolution success/failure without exporting hostnames or resolved addresses. Current spans show timing but do not explicitly classify the result.
|
- [ ] Record hostname-resolution success/failure without exporting hostnames or resolved addresses. Current spans show timing but do not explicitly classify the result.
|
||||||
- [ ] Investigate the slow resolver with a reproducible device-side check. Timing isolated the phase, not the cause; do not treat instrumentation as a fix.
|
- [ ] Investigate the slow resolver with a reproducible device-side check. Timing isolated the phase, not the cause; do not treat instrumentation as a fix.
|
||||||
- [x] Propagate optional W3C trace context for periodic/requested WS snapshots so agent collection and CC persistence can be inspected together. Wire compatibility and context linkage tested; live validation pending.
|
- [x] Propagate optional W3C trace context for periodic/requested WS snapshots so agent collection and CC persistence can be inspected together. Wire compatibility and context linkage tested; live validation pending.
|
||||||
@@ -52,7 +53,7 @@ or current fleet size. Metric labels should remain bounded; no per-device labels
|
|||||||
|
|
||||||
## Validation And Reliability
|
## Validation And Reliability
|
||||||
|
|
||||||
- [ ] Bound fleet-cache rebuild retries under sustained mutations; current stale-build rejection can keep a request waiting while inputs continuously change. Do not silently serve stale wake routes as a fallback.
|
- [x] Bound fleet-cache rebuilds to three attempts under sustained mutations; return retryable HTTP 503 `fleet_busy` rather than silently serving stale wake routes. Deterministic limit/recovery tests included.
|
||||||
- [ ] Reproduce/check Ctrl-C cleanup on the test device: process groups, PTYs, relay tasks, and telemetry shutdown. Leftovers were suspected, not confirmed.
|
- [ ] Reproduce/check Ctrl-C cleanup on the test device: process groups, PTYs, relay tasks, and telemetry shutdown. Leftovers were suspected, not confirmed.
|
||||||
- [ ] Exercise collector unavailability and recovery with running agent/CC instances. Verify normal operations continue and telemetry queues remain bounded; outage losses are expected under the current best-effort policy.
|
- [ ] Exercise collector unavailability and recovery with running agent/CC instances. Verify normal operations continue and telemetry queues remain bounded; outage losses are expected under the current best-effort policy.
|
||||||
- [ ] Validate terminal recovery instrumentation live: generation timing/size, transport attach/resume, and backpressure episodes ending in drain or disconnect.
|
- [ ] Validate terminal recovery instrumentation live: generation timing/size, transport attach/resume, and backpressure episodes ending in drain or disconnect.
|
||||||
|
|||||||
+12
-1
@@ -153,9 +153,20 @@ dashboards are not part of this initial slice.
|
|||||||
## Failure policy
|
## Failure policy
|
||||||
|
|
||||||
Trace and log queues are capped at 256 records each, with export batches of 64
|
Trace and log queues are capped at 256 records each, with export batches of 64
|
||||||
and a 3-second request timeout. Export happens off the application path. On an
|
and a 3-second request timeout. Export happens off the application path. The
|
||||||
|
OTLP exporters use their recommended retry policy: up to three retries after
|
||||||
|
the first attempt, exponential backoff and jitter, within the exporter timeout
|
||||||
|
budget. Temporary transport/receiver failures can recover; authentication
|
||||||
|
rejection and partial-success responses are not retried. Requests already in
|
||||||
|
flight can exceed the retry budget, so it is not a hard wall-clock deadline.
|
||||||
|
The application does not wrap exports in another retry loop. On an
|
||||||
outage, telemetry can be dropped rather than accumulating unbounded router RAM;
|
outage, telemetry can be dropped rather than accumulating unbounded router RAM;
|
||||||
terminal output follows its existing lossless backpressure policy independently.
|
terminal output follows its existing lossless backpressure policy independently.
|
||||||
|
|
||||||
|
Automated receiver tests reject the first export of each signal and confirm
|
||||||
|
recovery, check the retry-attempt limit on a persistent outage, and confirm that
|
||||||
|
bad credentials cause only one attempt. These tests do not establish durable
|
||||||
|
delivery during a long outage; bounded queues can still drop telemetry.
|
||||||
The bound is a record count, not an absolute byte limit on arbitrary log messages.
|
The bound is a record count, not an absolute byte limit on arbitrary log messages.
|
||||||
Providers flush on orderly daemon exit (Ctrl-C/SIGTERM), on a blocking worker while
|
Providers flush on orderly daemon exit (Ctrl-C/SIGTERM), on a blocking worker while
|
||||||
Tokio remains alive. Only completed spans are exported; this is not graceful draining
|
Tokio remains alive. Only completed spans are exported; this is not graceful draining
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ async fn fleet_view(state: &AppState) -> anyhow::Result<std::sync::Arc<Vec<Fleet
|
|||||||
|
|
||||||
const REFRESH_CONCURRENCY: usize = 8;
|
const REFRESH_CONCURRENCY: usize = 8;
|
||||||
|
|
||||||
|
fn fleet_error(err: anyhow::Error, fallback_code: &str) -> ApiError {
|
||||||
|
if err.is::<crate::runtime::view_cache::RebuildLimit>() {
|
||||||
|
return ApiError::new(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"fleet_busy",
|
||||||
|
err.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ApiError::new(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
fallback_code,
|
||||||
|
err.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "fleet_list", skip_all)]
|
#[tracing::instrument(name = "fleet_list", skip_all)]
|
||||||
pub async fn list_fleet_devices(
|
pub async fn list_fleet_devices(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -60,11 +75,7 @@ pub async fn list_fleet_devices(
|
|||||||
Ok(devices) => Ok((StatusCode::OK, Json(devices))),
|
Ok(devices) => Ok((StatusCode::OK, Json(devices))),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(error = %err, "failed to list fleet devices");
|
warn!(error = %err, "failed to list fleet devices");
|
||||||
Err(ApiError::new(
|
Err(fleet_error(err, "list_fleet_devices_failed"))
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"list_fleet_devices_failed",
|
|
||||||
err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,11 +237,7 @@ pub async fn wake_fleet_device(
|
|||||||
};
|
};
|
||||||
let devices = load_fleet_devices(&state, &query).await.map_err(|err| {
|
let devices = load_fleet_devices(&state, &query).await.map_err(|err| {
|
||||||
warn!(error = %err, "failed loading fleet devices for wake");
|
warn!(error = %err, "failed loading fleet devices for wake");
|
||||||
ApiError::new(
|
fleet_error(err, "load_fleet_devices_failed")
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"load_fleet_devices_failed",
|
|
||||||
err.to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let device = devices
|
let device = devices
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ use super::normalize_refresh_agent_ids;
|
|||||||
use super::types::ListFleetDevicesQuery;
|
use super::types::ListFleetDevicesQuery;
|
||||||
use crate::state::{AgentDeviceRow, AgentDeviceWithChildren, DeviceIdentifier, KnownDevice};
|
use crate::state::{AgentDeviceRow, AgentDeviceWithChildren, DeviceIdentifier, KnownDevice};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_rebuild_limit_is_retryable_but_db_failures_keep_their_error_code() {
|
||||||
|
let busy = super::fleet_error(
|
||||||
|
crate::runtime::view_cache::RebuildLimit.into(),
|
||||||
|
"list_fleet_devices_failed",
|
||||||
|
);
|
||||||
|
assert_eq!(busy.status, axum::http::StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
assert_eq!(busy.code, "fleet_busy");
|
||||||
|
let failed = super::fleet_error(
|
||||||
|
anyhow::anyhow!("database failed"),
|
||||||
|
"list_fleet_devices_failed",
|
||||||
|
);
|
||||||
|
assert_eq!(failed.status, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
assert_eq!(failed.code, "list_fleet_devices_failed");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fleet_cache_tracks_store_edits_and_connections_without_caching_filters() {
|
async fn fleet_cache_tracks_store_edits_and_connections_without_caching_filters() {
|
||||||
use crate::runtime::{AgentSession, AppState};
|
use crate::runtime::{AgentSession, AppState};
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
const MAX_REBUILDS: usize = 3;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct RebuildLimit;
|
||||||
|
|
||||||
|
impl std::fmt::Display for RebuildLimit {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("view inputs kept changing during rebuilding; retry the request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for RebuildLimit {}
|
||||||
|
|
||||||
pub(crate) struct ViewCache<K, V> {
|
pub(crate) struct ViewCache<K, V> {
|
||||||
entry: Mutex<Option<(K, Arc<V>)>>,
|
entry: Mutex<Option<(K, Arc<V>)>>,
|
||||||
}
|
}
|
||||||
@@ -21,19 +34,20 @@ impl<K: Eq, V> ViewCache<K, V> {
|
|||||||
/// Reuse a matching view or rebuild it. `key` must reflect every input to
|
/// Reuse a matching view or rebuild it. `key` must reflect every input to
|
||||||
/// `build`; rechecking it prevents an in-flight old build being published.
|
/// `build`; rechecking it prevents an in-flight old build being published.
|
||||||
/// Errors are not cached, and cancellation releases the rebuild lock.
|
/// Errors are not cached, and cancellation releases the rebuild lock.
|
||||||
pub(crate) async fn get_or_try_init<E, KF, BF>(
|
/// Three invalidated builds return `RebuildLimit` rather than starving readers.
|
||||||
|
pub(crate) async fn get_or_try_init<KF, BF>(
|
||||||
&self,
|
&self,
|
||||||
key: impl Fn() -> KF,
|
key: impl Fn() -> KF,
|
||||||
build: impl Fn() -> BF,
|
build: impl Fn() -> BF,
|
||||||
) -> Result<Arc<V>, E>
|
) -> anyhow::Result<Arc<V>>
|
||||||
where
|
where
|
||||||
KF: Future<Output = K>,
|
KF: Future<Output = K>,
|
||||||
BF: Future<Output = Result<V, E>>,
|
BF: Future<Output = anyhow::Result<V>>,
|
||||||
{
|
{
|
||||||
// Keep publication and rebuilding under one lock. Waiting readers then
|
// Keep publication and rebuilding under one lock. Waiting readers then
|
||||||
// reuse the completed view rather than launching duplicate DB work.
|
// reuse the completed view rather than launching duplicate DB work.
|
||||||
let mut entry = self.entry.lock().await;
|
let mut entry = self.entry.lock().await;
|
||||||
loop {
|
for _ in 0..MAX_REBUILDS {
|
||||||
let before = key().await;
|
let before = key().await;
|
||||||
if let Some((stored_key, value)) = &*entry
|
if let Some((stored_key, value)) = &*entry
|
||||||
&& *stored_key == before
|
&& *stored_key == before
|
||||||
@@ -47,6 +61,7 @@ impl<K: Eq, V> ViewCache<K, V> {
|
|||||||
return Ok(value);
|
return Ok(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(RebuildLimit.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +80,7 @@ mod tests {
|
|||||||
let build = || async {
|
let build = || async {
|
||||||
builds.fetch_add(1, Ordering::SeqCst);
|
builds.fetch_add(1, Ordering::SeqCst);
|
||||||
tokio::task::yield_now().await;
|
tokio::task::yield_now().await;
|
||||||
Ok::<_, ()>(42)
|
Ok::<_, anyhow::Error>(42)
|
||||||
};
|
};
|
||||||
let (a, b) = tokio::join!(
|
let (a, b) = tokio::join!(
|
||||||
cache.get_or_try_init(key, build),
|
cache.get_or_try_init(key, build),
|
||||||
@@ -93,7 +108,7 @@ mod tests {
|
|||||||
started.notify_one();
|
started.notify_one();
|
||||||
release.notified().await;
|
release.notified().await;
|
||||||
}
|
}
|
||||||
Ok::<_, ()>(captured)
|
Ok::<_, anyhow::Error>(captured)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let writer = async {
|
let writer = async {
|
||||||
@@ -111,13 +126,16 @@ mod tests {
|
|||||||
let cache = ViewCache::default();
|
let cache = ViewCache::default();
|
||||||
assert!(
|
assert!(
|
||||||
cache
|
cache
|
||||||
.get_or_try_init(|| async { 0 }, || async { Err::<u8, _>("db failed") })
|
.get_or_try_init(
|
||||||
|
|| async { 0 },
|
||||||
|
|| async { Err::<u8, _>(anyhow::anyhow!("db failed")) }
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*cache
|
*cache
|
||||||
.get_or_try_init(|| async { 0 }, || async { Ok::<_, &str>(7) })
|
.get_or_try_init(|| async { 0 }, || async { Ok::<_, anyhow::Error>(7) })
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
7
|
7
|
||||||
@@ -137,7 +155,7 @@ mod tests {
|
|||||||
|| async { 0 },
|
|| async { 0 },
|
||||||
|| async {
|
|| async {
|
||||||
started.notify_one();
|
started.notify_one();
|
||||||
std::future::pending::<Result<u8, ()>>().await
|
std::future::pending::<anyhow::Result<u8>>().await
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -148,10 +166,41 @@ mod tests {
|
|||||||
assert!(task.await.unwrap_err().is_cancelled());
|
assert!(task.await.unwrap_err().is_cancelled());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*cache
|
*cache
|
||||||
.get_or_try_init(|| async { 0 }, || async { Ok::<_, ()>(7) })
|
.get_or_try_init(|| async { 0 }, || async { Ok::<_, anyhow::Error>(7) })
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
7
|
7
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sustained_changes_stop_at_the_limit_and_a_later_read_recovers() {
|
||||||
|
let cache = ViewCache::default();
|
||||||
|
let revision = AtomicU64::new(0);
|
||||||
|
let key = || async { revision.load(Ordering::SeqCst) };
|
||||||
|
cache
|
||||||
|
.get_or_try_init(key, || async { Ok(0_u8) })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
revision.store(1, Ordering::SeqCst);
|
||||||
|
let builds = AtomicU64::new(0);
|
||||||
|
let error = cache
|
||||||
|
.get_or_try_init(key, || async {
|
||||||
|
builds.fetch_add(1, Ordering::SeqCst);
|
||||||
|
revision.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(1)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.is::<RebuildLimit>());
|
||||||
|
assert_eq!(builds.load(Ordering::SeqCst), MAX_REBUILDS as u64);
|
||||||
|
let recovered = cache
|
||||||
|
.get_or_try_init(key, || async { Ok(2) })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
*recovered, 2,
|
||||||
|
"must not fall back to the previous cached value"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,12 +6,12 @@ publish = ["gitea"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true, optional = true }
|
anyhow = { workspace = true, optional = true }
|
||||||
opentelemetry = { version = "0.31", optional = true }
|
opentelemetry = { version = "0.33", optional = true }
|
||||||
opentelemetry-otlp = { version = "0.31", default-features = false, features = ["grpc-tonic", "trace", "logs", "metrics"], optional = true }
|
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["grpc-tonic", "trace", "logs", "metrics"], optional = true }
|
||||||
opentelemetry_sdk = { version = "0.31", optional = true }
|
opentelemetry_sdk = { version = "0.33", optional = true }
|
||||||
opentelemetry-appender-tracing = { version = "0.31", features = ["experimental_use_tracing_span_context"], optional = true }
|
opentelemetry-appender-tracing = { version = "0.33", optional = true }
|
||||||
tracing = { workspace = true, optional = true }
|
tracing = { workspace = true, optional = true }
|
||||||
tracing-opentelemetry = { version = "0.32", optional = true }
|
tracing-opentelemetry = { version = "0.34", optional = true }
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"], optional = true }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"], optional = true }
|
||||||
time = { version = "0.3", features = ["formatting", "local-offset"], optional = true }
|
time = { version = "0.3", features = ["formatting", "local-offset"], optional = true }
|
||||||
tonic = { version = "0.14", default-features = false, optional = true }
|
tonic = { version = "0.14", default-features = false, optional = true }
|
||||||
@@ -31,4 +31,4 @@ serde_json.workspace = true
|
|||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "time"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "time"] }
|
||||||
tokio-stream = { version = "0.1", features = ["net"] }
|
tokio-stream = { version = "0.1", features = ["net"] }
|
||||||
tonic = "0.14"
|
tonic = "0.14"
|
||||||
opentelemetry-proto = { version = "0.31", default-features = false, features = ["gen-tonic", "trace", "logs", "metrics"] }
|
opentelemetry-proto = { version = "0.33", default-features = false, features = ["gen-tonic", "trace", "logs", "metrics"] }
|
||||||
@@ -105,11 +105,14 @@ pub fn init(
|
|||||||
}
|
}
|
||||||
let resource = resource.build();
|
let resource = resource.build();
|
||||||
// Small fixed queues are intentional: a disconnected collector cannot grow router RAM.
|
// Small fixed queues are intentional: a disconnected collector cannot grow router RAM.
|
||||||
|
// Exporters own transient retries/backoff; authentication failures are
|
||||||
|
// not retried. The timeout remains their budget, not a durable outbox.
|
||||||
let trace_exporter = opentelemetry_otlp::SpanExporter::builder()
|
let trace_exporter = opentelemetry_otlp::SpanExporter::builder()
|
||||||
.with_tonic()
|
.with_tonic()
|
||||||
.with_metadata(metadata.clone())
|
.with_metadata(metadata.clone())
|
||||||
.with_endpoint(endpoint)
|
.with_endpoint(endpoint)
|
||||||
.with_timeout(Duration::from_secs(3))
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.with_retry_policy(opentelemetry_otlp::RetryPolicy::recommended())
|
||||||
.build()
|
.build()
|
||||||
.context("OTLP trace exporter")?;
|
.context("OTLP trace exporter")?;
|
||||||
let trace_processor = opentelemetry_sdk::trace::BatchSpanProcessor::builder(trace_exporter)
|
let trace_processor = opentelemetry_sdk::trace::BatchSpanProcessor::builder(trace_exporter)
|
||||||
@@ -129,6 +132,7 @@ pub fn init(
|
|||||||
.with_metadata(metadata.clone())
|
.with_metadata(metadata.clone())
|
||||||
.with_endpoint(endpoint)
|
.with_endpoint(endpoint)
|
||||||
.with_timeout(Duration::from_secs(3))
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.with_retry_policy(opentelemetry_otlp::RetryPolicy::recommended())
|
||||||
.build()
|
.build()
|
||||||
.context("OTLP log exporter")?;
|
.context("OTLP log exporter")?;
|
||||||
let log_processor = opentelemetry_sdk::logs::BatchLogProcessor::builder(log_exporter)
|
let log_processor = opentelemetry_sdk::logs::BatchLogProcessor::builder(log_exporter)
|
||||||
@@ -148,6 +152,7 @@ pub fn init(
|
|||||||
.with_metadata(metadata)
|
.with_metadata(metadata)
|
||||||
.with_endpoint(endpoint)
|
.with_endpoint(endpoint)
|
||||||
.with_timeout(Duration::from_secs(3))
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.with_retry_policy(opentelemetry_otlp::RetryPolicy::recommended())
|
||||||
.build()
|
.build()
|
||||||
.context("OTLP metric exporter")?;
|
.context("OTLP metric exporter")?;
|
||||||
let reader = opentelemetry_sdk::metrics::PeriodicReader::builder(metric_exporter)
|
let reader = opentelemetry_sdk::metrics::PeriodicReader::builder(metric_exporter)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use opentelemetry_proto::tonic::collector::{
|
use opentelemetry_proto::tonic::collector::{
|
||||||
logs::v1 as logs, metrics::v1 as metrics, trace::v1 as traces,
|
logs::v1 as logs, metrics::v1 as metrics, trace::v1 as traces,
|
||||||
};
|
};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tonic::{Request, Response, Status};
|
use tonic::{Request, Response, Status};
|
||||||
use wakey_core::telemetry::{self, TelemetryConfig};
|
use wakey_core::telemetry::{self, TelemetryConfig};
|
||||||
@@ -12,6 +13,8 @@ struct Collector {
|
|||||||
traces: Arc<Mutex<Vec<traces::ExportTraceServiceRequest>>>,
|
traces: Arc<Mutex<Vec<traces::ExportTraceServiceRequest>>>,
|
||||||
logs: Arc<Mutex<Vec<logs::ExportLogsServiceRequest>>>,
|
logs: Arc<Mutex<Vec<logs::ExportLogsServiceRequest>>>,
|
||||||
metrics: Arc<Mutex<Vec<metrics::ExportMetricsServiceRequest>>>,
|
metrics: Arc<Mutex<Vec<metrics::ExportMetricsServiceRequest>>>,
|
||||||
|
fail_until: usize,
|
||||||
|
attempts: [Arc<AtomicUsize>; 3],
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_auth<T>(request: &Request<T>) -> Result<(), Status> {
|
fn check_auth<T>(request: &Request<T>) -> Result<(), Status> {
|
||||||
@@ -28,13 +31,78 @@ fn check_auth<T>(request: &Request<T>) -> Result<(), Status> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Use a real receiver and counted RPCs, not sleep/elapsed-time expectations.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn exporter_retries_are_bounded_and_bad_credentials_are_not_retried() {
|
||||||
|
use opentelemetry_otlp::{RetryPolicy, WithExportConfig, WithTonicConfig};
|
||||||
|
use opentelemetry_sdk::trace::SpanExporter as _;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let collector = Collector {
|
||||||
|
fail_until: usize::MAX,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let server = tokio::spawn(
|
||||||
|
tonic::transport::Server::builder()
|
||||||
|
.add_service(traces::trace_service_server::TraceServiceServer::new(
|
||||||
|
collector.clone(),
|
||||||
|
))
|
||||||
|
.serve_with_incoming_shutdown(
|
||||||
|
tokio_stream::wrappers::TcpListenerStream::new(listener),
|
||||||
|
async {
|
||||||
|
let _ = stop_rx.await;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let mut metadata = tonic::metadata::MetadataMap::new();
|
||||||
|
metadata.insert("authorization", "Basic dGVzdDp0ZXN0".parse().unwrap());
|
||||||
|
let policy = RetryPolicy::recommended()
|
||||||
|
.with_max_retries(2)
|
||||||
|
.with_initial_delay(Duration::from_millis(1))
|
||||||
|
.with_max_delay(Duration::from_millis(4))
|
||||||
|
.with_max_jitter(Duration::ZERO);
|
||||||
|
let exporter = opentelemetry_otlp::SpanExporter::builder()
|
||||||
|
.with_tonic()
|
||||||
|
.with_endpoint(&endpoint)
|
||||||
|
.with_metadata(metadata)
|
||||||
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.with_retry_policy(policy.clone())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert!(exporter.export(vec![]).await.is_err());
|
||||||
|
assert_eq!(collector.attempts[0].load(Ordering::SeqCst), 3);
|
||||||
|
|
||||||
|
let unauthorized = opentelemetry_otlp::SpanExporter::builder()
|
||||||
|
.with_tonic()
|
||||||
|
.with_endpoint(endpoint)
|
||||||
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.with_retry_policy(policy)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert!(unauthorized.export(vec![]).await.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
collector.attempts[0].load(Ordering::SeqCst),
|
||||||
|
4,
|
||||||
|
"authentication failure must add only one attempt"
|
||||||
|
);
|
||||||
|
let _ = stop_tx.send(());
|
||||||
|
server.await.unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tonic::async_trait]
|
#[tonic::async_trait]
|
||||||
impl traces::trace_service_server::TraceService for Collector {
|
impl traces::trace_service_server::TraceService for Collector {
|
||||||
async fn export(
|
async fn export(
|
||||||
&self,
|
&self,
|
||||||
request: Request<traces::ExportTraceServiceRequest>,
|
request: Request<traces::ExportTraceServiceRequest>,
|
||||||
) -> Result<Response<traces::ExportTraceServiceResponse>, Status> {
|
) -> Result<Response<traces::ExportTraceServiceResponse>, Status> {
|
||||||
|
let attempt = self.attempts[0].fetch_add(1, Ordering::SeqCst);
|
||||||
check_auth(&request)?;
|
check_auth(&request)?;
|
||||||
|
if attempt < self.fail_until {
|
||||||
|
return Err(Status::unavailable("temporary receiver outage"));
|
||||||
|
}
|
||||||
self.traces.lock().unwrap().push(request.into_inner());
|
self.traces.lock().unwrap().push(request.into_inner());
|
||||||
Ok(Response::new(Default::default()))
|
Ok(Response::new(Default::default()))
|
||||||
}
|
}
|
||||||
@@ -45,7 +113,11 @@ impl logs::logs_service_server::LogsService for Collector {
|
|||||||
&self,
|
&self,
|
||||||
request: Request<logs::ExportLogsServiceRequest>,
|
request: Request<logs::ExportLogsServiceRequest>,
|
||||||
) -> Result<Response<logs::ExportLogsServiceResponse>, Status> {
|
) -> Result<Response<logs::ExportLogsServiceResponse>, Status> {
|
||||||
|
let attempt = self.attempts[1].fetch_add(1, Ordering::SeqCst);
|
||||||
check_auth(&request)?;
|
check_auth(&request)?;
|
||||||
|
if attempt < self.fail_until {
|
||||||
|
return Err(Status::unavailable("temporary receiver outage"));
|
||||||
|
}
|
||||||
self.logs.lock().unwrap().push(request.into_inner());
|
self.logs.lock().unwrap().push(request.into_inner());
|
||||||
Ok(Response::new(Default::default()))
|
Ok(Response::new(Default::default()))
|
||||||
}
|
}
|
||||||
@@ -56,7 +128,11 @@ impl metrics::metrics_service_server::MetricsService for Collector {
|
|||||||
&self,
|
&self,
|
||||||
request: Request<metrics::ExportMetricsServiceRequest>,
|
request: Request<metrics::ExportMetricsServiceRequest>,
|
||||||
) -> Result<Response<metrics::ExportMetricsServiceResponse>, Status> {
|
) -> Result<Response<metrics::ExportMetricsServiceResponse>, Status> {
|
||||||
|
let attempt = self.attempts[2].fetch_add(1, Ordering::SeqCst);
|
||||||
check_auth(&request)?;
|
check_auth(&request)?;
|
||||||
|
if attempt < self.fail_until {
|
||||||
|
return Err(Status::unavailable("temporary receiver outage"));
|
||||||
|
}
|
||||||
self.metrics.lock().unwrap().push(request.into_inner());
|
self.metrics.lock().unwrap().push(request.into_inner());
|
||||||
Ok(Response::new(Default::default()))
|
Ok(Response::new(Default::default()))
|
||||||
}
|
}
|
||||||
@@ -68,6 +144,7 @@ async fn exports_all_signals_and_preserves_remote_command_parent() {
|
|||||||
// Exercise the real global subscriber without mutating this process's environment
|
// Exercise the real global subscriber without mutating this process's environment
|
||||||
// or inheriting a developer's RUST_LOG=warn/off setting.
|
// or inheriting a developer's RUST_LOG=warn/off setting.
|
||||||
if std::env::var_os("WAKEY_OTLP_TEST_CHILD").is_none() {
|
if std::env::var_os("WAKEY_OTLP_TEST_CHILD").is_none() {
|
||||||
|
for retry in ["0", "1"] {
|
||||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
.args([
|
.args([
|
||||||
"--exact",
|
"--exact",
|
||||||
@@ -75,6 +152,7 @@ async fn exports_all_signals_and_preserves_remote_command_parent() {
|
|||||||
"--nocapture",
|
"--nocapture",
|
||||||
])
|
])
|
||||||
.env("WAKEY_OTLP_TEST_CHILD", "1")
|
.env("WAKEY_OTLP_TEST_CHILD", "1")
|
||||||
|
.env("WAKEY_OTLP_TEST_RETRY", retry)
|
||||||
.env("RUST_LOG", "info")
|
.env("RUST_LOG", "info")
|
||||||
.env("WAKEY_OTLP_TEST_AUTH", "Basic dGVzdDp0ZXN0")
|
.env("WAKEY_OTLP_TEST_AUTH", "Basic dGVzdDp0ZXN0")
|
||||||
.output()
|
.output()
|
||||||
@@ -90,11 +168,16 @@ async fn exports_all_signals_and_preserves_remote_command_parent() {
|
|||||||
.contains("debug log exported without console output")
|
.contains("debug log exported without console output")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
||||||
let collector = Collector::default();
|
let retry = std::env::var("WAKEY_OTLP_TEST_RETRY").as_deref() == Ok("1");
|
||||||
|
let collector = Collector {
|
||||||
|
fail_until: usize::from(retry),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
|
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
|
||||||
let server = tonic::transport::Server::builder()
|
let server = tonic::transport::Server::builder()
|
||||||
.add_service(traces::trace_service_server::TraceServiceServer::new(
|
.add_service(traces::trace_service_server::TraceServiceServer::new(
|
||||||
@@ -159,6 +242,13 @@ async fn exports_all_signals_and_preserves_remote_command_parent() {
|
|||||||
let _ = stop_tx.send(());
|
let _ = stop_tx.send(());
|
||||||
server.await.unwrap().unwrap();
|
server.await.unwrap().unwrap();
|
||||||
|
|
||||||
|
for attempts in &collector.attempts {
|
||||||
|
assert!(
|
||||||
|
attempts.load(Ordering::SeqCst) > usize::from(retry),
|
||||||
|
"each signal must recover from its first failed export"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let traces = collector.traces.lock().unwrap();
|
let traces = collector.traces.lock().unwrap();
|
||||||
let spans: Vec<_> = traces
|
let spans: Vec<_> = traces
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
Reference in new issue
Block a user