Skip to content

Commit 4e43c37

Browse files
authored
[datastore] Lookup expunged Oximeter returns Ok(None) (#9651)
This is a small change pulled out of upcoming blueprint pruning work. Reconfigurator needs to be able to look up a potentially-expunged Oximeter instance and know whether the cleanup work that marks it expunged in the `oximeter` crdb table is complete. It's easy to know if it's _not_ complete: we'll get back some Oximeter info. But if the cleanup work has happened, prior to this PR, we'd get back an error, and the details get squashed down by way of `public_error_from_diesel()` into an `Error::InternalError { ... }`. We need to be able to tell the difference between "lookup failed because there is no non-expunged Oximeter by this ID" and "query failed for $DATABASE_REASON", so this PR changes the lookup to return `Result<Option<OximeterInfo>, _>`, allowing us to return `Ok(None)` for the "query succeeded but no non-expunged Oximeter exists" case. This required changing both callers, and in doing so I think the (rare) case of trying to operate on an expunged Oximeter would fail prior to this PR but will now succeed, because both are trying to notify Oximeter of no-longer-present producers. While I was there, I added a tiny bit of in-memory caching (thrown away on each activation) to `ExpiredProducers::new()`. Previously, it would issue a db lookup for the assigned Oximeter ID of every expired producer. However: * If we have any expired producers, we're likely to have several (e.g., from a sled going down - all its producers will expire at close to the same time) * We only run one Oximeter instance at a time, so every producer is almost certain to be assigned to the same Oximeter which meant we'd be issuing "num expired producers" queries for the same information. We'll now only issue one query for each unique Oximeter ID (almost always 1).
1 parent c62ec04 commit 4e43c37

3 files changed

Lines changed: 181 additions & 23 deletions

File tree

nexus/db-queries/src/db/datastore/oximeter.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,19 @@ pub enum CollectorReassignment {
4141
impl DataStore {
4242
/// Lookup an oximeter instance by its ID.
4343
///
44-
/// Fails if the instance has been expunged.
44+
/// Returns `Ok(None)` if the instance does not exist or has been expunged.
4545
pub async fn oximeter_lookup(
4646
&self,
4747
opctx: &OpContext,
4848
id: &Uuid,
49-
) -> Result<OximeterInfo, Error> {
49+
) -> Result<Option<OximeterInfo>, Error> {
5050
use nexus_db_schema::schema::oximeter::dsl;
5151
dsl::oximeter
5252
.filter(dsl::time_expunged.is_null())
5353
.find(*id)
5454
.first_async(&*self.pool_connection_authorized(opctx).await?)
5555
.await
56+
.optional()
5657
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))
5758
}
5859

nexus/metrics-producer-gc/src/lib.rs

Lines changed: 161 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use futures::stream::FuturesUnordered;
1717
use nexus_db_queries::context::OpContext;
1818
use nexus_db_queries::db::DataStore;
1919
use nexus_db_queries::db::identity::Asset;
20+
use nexus_db_queries::db::model::OximeterInfo;
2021
use nexus_db_queries::db::model::ProducerEndpoint;
2122
use omicron_common::api::external::Error as DbError;
2223
use oximeter_client::Client as OximeterClient;
@@ -42,7 +43,7 @@ pub enum Error {
4243
#[error("failed to list expired producers")]
4344
ListExpiredProducers(#[source] DbError),
4445
#[error("failed to get Oximeter info for {id}")]
45-
GetOximterInfo {
46+
GetOximeterInfo {
4647
id: Uuid,
4748
#[source]
4849
err: DbError,
@@ -62,12 +63,21 @@ pub async fn prune_expired_producers(
6263
// Build a FuturesUnordered to prune each expired producer.
6364
let mut all_prunes = expired_producers
6465
.producer_client_pairs()
65-
.map(|(producer, client)| async {
66-
let result = unregister_producer(
67-
opctx, datastore, producer, client, &opctx.log,
68-
)
69-
.await;
70-
(producer.id(), result)
66+
.map(|(producer, maybe_client)| async move {
67+
match maybe_client {
68+
Some(client) => {
69+
let result = unregister_producer(
70+
opctx, datastore, producer, client, &opctx.log,
71+
)
72+
.await;
73+
(producer.id(), result)
74+
}
75+
// Treat "no client" as success: the only way to have no client
76+
// from `expired_producers` is if this producer's Oximeter no
77+
// longer exists (in which case we don't need to tell it about
78+
// this expired producer!).
79+
None => (producer.id(), Ok(())),
80+
}
7181
})
7282
.collect::<FuturesUnordered<_>>();
7383

@@ -139,18 +149,27 @@ impl ExpiredProducers {
139149
.map_err(Error::ListExpiredProducers)?;
140150

141151
let mut clients = BTreeMap::new();
152+
let mut oximeter_by_id = OximeterInfoById::default();
142153
for producer in &producers {
143154
let entry = match clients.entry(producer.oximeter_id) {
144155
Entry::Vacant(entry) => entry,
145156
Entry::Occupied(_) => continue,
146157
};
147-
let info = datastore
148-
.oximeter_lookup(opctx, &producer.oximeter_id)
149-
.await
150-
.map_err(|err| Error::GetOximterInfo {
151-
id: producer.oximeter_id,
152-
err,
153-
})?;
158+
let Some(info) = oximeter_by_id
159+
.get(opctx, datastore, producer.oximeter_id)
160+
.await?
161+
else {
162+
// If the Oximeter for this producer doesn't exist, that's fine
163+
// - we don't need to tell it this producer is gone.
164+
info!(
165+
opctx.log,
166+
"Oximeter instance not found (presumed expunged); \
167+
skipping notification of expired producer";
168+
"collector-id" => %producer.oximeter_id,
169+
"producer-id" => %producer.id(),
170+
);
171+
continue;
172+
};
154173
let client_log =
155174
opctx.log.new(o!("oximeter-collector" => info.id.to_string()));
156175
let address = SocketAddr::new(info.ip.ip(), *info.port);
@@ -164,16 +183,42 @@ impl ExpiredProducers {
164183

165184
fn producer_client_pairs(
166185
&self,
167-
) -> impl Iterator<Item = (&ProducerEndpoint, &OximeterClient)> {
186+
) -> impl Iterator<Item = (&ProducerEndpoint, Option<&OximeterClient>)>
187+
{
168188
self.producers.iter().map(|producer| {
169-
// In `new()` we add a client for every producer.oximeter_id, so we
170-
// can unwrap this lookup.
171-
let client = self.clients.get(&producer.oximeter_id).unwrap();
172-
(producer, client)
189+
let maybe_client = self.clients.get(&producer.oximeter_id);
190+
(producer, maybe_client)
173191
})
174192
}
175193
}
176194

195+
#[derive(Debug, Default)]
196+
struct OximeterInfoById(BTreeMap<Uuid, Option<OximeterInfo>>);
197+
198+
impl OximeterInfoById {
199+
async fn get(
200+
&mut self,
201+
opctx: &OpContext,
202+
datastore: &DataStore,
203+
id: Uuid,
204+
) -> Result<Option<&OximeterInfo>, Error> {
205+
// If we've already looked up this Oximeter's info, return it.
206+
let vacant_entry = match self.0.entry(id) {
207+
Entry::Vacant(entry) => entry,
208+
Entry::Occupied(entry) => return Ok(entry.into_mut().as_ref()),
209+
};
210+
211+
// Otherwise, try to look it up. If this succeeds, we'll insert the
212+
// `info` into `vacant_entry` so we don't have to look it up again.
213+
let info = datastore
214+
.oximeter_lookup(opctx, &id)
215+
.await
216+
.map_err(|err| Error::GetOximeterInfo { id, err })?;
217+
218+
Ok(vacant_entry.insert(info).as_ref())
219+
}
220+
}
221+
177222
#[cfg(test)]
178223
mod tests {
179224
use super::*;
@@ -186,6 +231,7 @@ mod tests {
186231
use nexus_db_model::OximeterInfo;
187232
use nexus_db_queries::db::pub_test_utils::TestDatabase;
188233
use nexus_types::internal_api::params;
234+
use omicron_common::api::external::DataPageParams;
189235
use omicron_common::api::internal::nexus;
190236
use omicron_test_utils::dev;
191237
use std::time::Duration;
@@ -293,6 +339,102 @@ mod tests {
293339
logctx.cleanup_successful();
294340
}
295341

342+
#[tokio::test]
343+
async fn test_prune_expired_producers_expunged_oximeter() {
344+
// Setup
345+
let logctx = dev::test_setup_log(
346+
"test_prune_expired_producers_expunged_oximeter",
347+
);
348+
let db = TestDatabase::new_with_datastore(&logctx.log).await;
349+
let (opctx, datastore) = (db.opctx(), db.datastore());
350+
351+
// Insert two Oximeter collectors
352+
let collector_info1 = OximeterInfo::new(&params::OximeterInfo {
353+
collector_id: Uuid::new_v4(),
354+
address: "[::1]:0".parse().unwrap(),
355+
});
356+
let collector_info2 = OximeterInfo::new(&params::OximeterInfo {
357+
collector_id: Uuid::new_v4(),
358+
address: "[::1]:0".parse().unwrap(),
359+
});
360+
datastore
361+
.oximeter_create(&opctx, &collector_info1)
362+
.await
363+
.expect("failed to insert collector");
364+
datastore
365+
.oximeter_create(&opctx, &collector_info2)
366+
.await
367+
.expect("failed to insert collector");
368+
369+
// Insert several producers; we hope to get at least one assigned to
370+
// each collector, so pick a sufficiently large number that almost all
371+
// test runs will do so.
372+
let total_producers = 32;
373+
let mut producers = Vec::new();
374+
for _ in 0..total_producers {
375+
let producer = nexus::ProducerEndpoint {
376+
id: Uuid::new_v4(),
377+
kind: nexus::ProducerKind::Service,
378+
address: "[::1]:0".parse().unwrap(), // unused
379+
interval: Duration::from_secs(0), // unused
380+
};
381+
datastore
382+
.producer_endpoint_upsert_and_assign(&opctx, &producer)
383+
.await
384+
.expect("failed to insert producer");
385+
producers.push(producer);
386+
}
387+
388+
// Confirm we created `total_producers`, with some split between the two
389+
// collectors.
390+
let num_assigned_to_collector_1 = datastore
391+
.producers_list_by_oximeter_id(
392+
opctx,
393+
collector_info1.id,
394+
&DataPageParams::max_page(),
395+
)
396+
.await
397+
.expect("listed producers")
398+
.len();
399+
let num_assigned_to_collector_2 = datastore
400+
.producers_list_by_oximeter_id(
401+
opctx,
402+
collector_info2.id,
403+
&DataPageParams::max_page(),
404+
)
405+
.await
406+
.expect("listed producers")
407+
.len();
408+
eprintln!("assigned to collector 1: {num_assigned_to_collector_1}");
409+
eprintln!("assigned to collector 2: {num_assigned_to_collector_2}");
410+
assert_eq!(
411+
num_assigned_to_collector_1 + num_assigned_to_collector_2,
412+
total_producers
413+
);
414+
415+
// Expunge one of the collectors.
416+
datastore
417+
.oximeter_expunge(opctx, collector_info1.id)
418+
.await
419+
.expect("expunged collector");
420+
421+
// Attempt to prune; this should report success for all
422+
// `total_producers` producers, even though some of them were assigned
423+
// to a now-expunged Oximeter.
424+
let pruned = prune_expired_producers(
425+
&opctx,
426+
&datastore,
427+
Utc::now() + Duration::from_secs(1),
428+
)
429+
.await
430+
.expect("failed to prune expired producers");
431+
assert_eq!(pruned.successes.len(), total_producers);
432+
assert!(pruned.failures.is_empty());
433+
434+
db.terminate().await;
435+
logctx.cleanup_successful();
436+
}
437+
296438
#[tokio::test]
297439
async fn test_prune_expired_producers_notifies_collector() {
298440
// Setup

nexus/src/app/oximeter.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,23 @@ pub(crate) async fn unassign_producer(
201201
"producer_id" => %id,
202202
"collector_id" => %collector_id,
203203
);
204-
let oximeter_info =
205-
datastore.oximeter_lookup(opctx, &collector_id).await?;
204+
205+
let Some(oximeter_info) =
206+
datastore.oximeter_lookup(opctx, &collector_id).await?
207+
else {
208+
// If we can't look up the Oximeter info, it must have been
209+
// expunged; that's sufficient to consider this producer
210+
// "unassigned".
211+
info!(
212+
log,
213+
"attempted to unassign metric producer, but Oximeter instance \
214+
not found - assuming it was expunged";
215+
"producer_id" => %id,
216+
"collector_id" => %collector_id,
217+
);
218+
return Ok(());
219+
};
220+
206221
let address =
207222
SocketAddr::new(oximeter_info.ip.ip(), *oximeter_info.port);
208223
let client = build_oximeter_client(&log, &id, address);

0 commit comments

Comments
 (0)