Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(9/5) [nexus] Allow anti-affinity members to be affinity groups #7572

Closed
wants to merge 27 commits into from

Conversation

smklein
Copy link
Collaborator

@smklein smklein commented Feb 21, 2025

Expands group membership for anti-affinity groups to allow both:

  • Instances (this existed before this PR), and
  • Affinity groups (this is new)
    ... to be anti-affine from each other.

Without this feature, putting instances in an anti-affinity group would force them all to be separate from
one another. However, this can be unsatisfactory when multiple instances in that anti-affinity group do not care
if they are co-located.

As an example: Suppose you have a collection of several VMMs each representing a vertical deployment of a stack, and you want redundancy between these groups at a "collection of VMMs" layer. It's fine - and sometimes desirable - for everything in that vertical deployment to be co-located. However, it's the "groups of VMMs" that need to be isolated from each other. With this PR, one could put each of these "VMM collections" into distinct affinity groups, and put each of those affinity groups within an anti-affinity group.

Fixes #7567

@smklein smklein changed the title Basic implementation of affinity group member within anti-affinity group [nexus] Allow anti-affinity members to be affinity groups Feb 21, 2025
@smklein smklein changed the title [nexus] Allow anti-affinity members to be affinity groups [9/5][nexus] Allow anti-affinity members to be affinity groups Feb 21, 2025
@smklein smklein changed the title [9/5][nexus] Allow anti-affinity members to be affinity groups (9/5) [nexus] Allow anti-affinity members to be affinity groups Feb 21, 2025

// If this affinity group is a member in other anti-affinity
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is tested by affinity_group_delete_group_deletes_membership_in_anti_affinity_groups below

})?;


// Check that the affinity group's members are not reserved.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is tested within anti_affinity_group_membership_add_remove_group_with_vmm

})?;
}
{
use db::schema::anti_affinity_group_affinity_membership::dsl as member_dsl;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expanded anti_affinity_group_delete_group_deletes_members to test this

err.bail_retryable_or_else(e, |e| {
public_error_from_diesel(
e,
ErrorHandler::NotFoundByResource(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expanded anti_affinity_group_membership_for_deleted_objects to check for these NotFound errors

err.bail_retryable_or_else(e, |e| {
public_error_from_diesel(
e,
ErrorHandler::NotFoundByResource(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above, I'm testing these NotFound errors in anti_affinity_group_membership_for_deleted_objects

pagparams: &PaginatedBy<'_>,
) -> ListResultVec<AntiAffinityGroupInstanceMembership> {
pagparams: &DataPageParams<'_, Uuid>,
) -> ListResultVec<external::AntiAffinityGroupMember> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is tested by anti_affinity_group_membership_list_extended

@smklein smklein marked this pull request as ready for review February 26, 2025 21:32
dropshot::PaginationOrder::Descending => false,
};

let mut query = QueryBuilder::new()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't use the paginated macro because it doesn't really support reading from the UNION of two tables, and IMO, it's easier to write the SQL manually that it is to mess with those trait bounds.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also experimenting with replacing the paginated macro with some utilities, closer to this implementation, in #7717

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file, as usual, contains the actual changes that we need to consider during instance placement.

I'll try to comment below to explain some of the rationale. For what it's worth, I also consider this to be the most "worth scrutinizing" file.

@@ -57,18 +57,70 @@ pub fn sled_find_targets_query(
COALESCE(SUM(CAST(sled_resource_vmm.reservoir_ram AS INT8)), 0) + "
).param().sql(" <= sled.reservoir_size
),
our_aa_groups AS (
our_a_groups AS (
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

our_a_groups and our_a_instances are pulled up from below, because we're now using them when evaluating what the "anti-affinity instances" should be.

ON affinity_group_instance_membership.group_id = our_a_groups.group_id
WHERE instance_id != ").param().sql("
),
our_direct_aa_groups AS (
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use the term "direct" to mean "this thing belongs as a member within the anti-affinity group we're observing".

I use the term "indirect" below for "this instance is being considered, but only because it's part of an affinity group that is a member within our anti-affinity group"

SELECT group_id
FROM anti_affinity_group_instance_membership
WHERE instance_id = ").param().sql("
),
other_aa_instances AS (
other_direct_aa_instances AS (
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are instances we're "anti-affine" with, due to their membership in an anti-affinity group our instance also belongs to.

Our Instance Other Instances
Member in anti-affinity group Member in anti-affinity group

WHERE instance_id != ").param().sql("
),
our_indirect_aa_groups AS (
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input to this whole query is "our instance ID".

We looked up the affinity groups this instance belongs to earlier - that's saved as our_a_groups.

So this subquery looks up "memberships in any anti-affinity groups, based on this instance's affinity groups".

This is "indirect" because our instance may not directly belong to this anti-affinity group, but we're looking it up because our instance needs to consider it through the affinity-group member relationship.

(The exactly_one_affinity_group value we return here is used below...)

FROM anti_affinity_group_affinity_membership
WHERE affinity_group_id IN (SELECT group_id FROM our_a_groups)
),
other_indirect_aa_instances_via_instances AS (
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are instances we're "anti-affine" with, due to their instance membership in the anti-affinity group and also due to our membership in an affinity group (see: our_indirect_aa_groups).

Our Instance Other Instances
Member in affinity group belonging to anti-affinity group Member in anti-affinity group

ELSE TRUE
END
),
other_aa_instances AS (
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... and this sums up all the cases of anti-affinity:

Our Instance Other Instances
Member in anti-affinity group Member in anti-affinity group
Member in affinity group belonging to anti-affinity group Member in anti-affinity group
Member in affinity group belonging to anti-affinity group Member in affinity group belonging to anti-affinity group

JOIN our_indirect_aa_groups
ON anti_affinity_group_instance_membership.group_id = our_indirect_aa_groups.anti_affinity_group_id
),
other_indirect_aa_instances_via_groups AS (
Copy link
Collaborator Author

@smklein smklein Feb 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are instances we're "anti-affine" with, due to their group membership in an anti-affinity group and due to our membership in an affinity group that also belongs to that anti-affinity group.

This is basically the "indirect in both directions case". Imagine:

  • Affinity group "sheep": inst1, inst2
  • Affinity group "wolves": inst3, inst4
  • Anti-affinity group "do-not-eat-sheep": "sheep", "wolves"

If we're trying to place "inst1", we are:

  • Indirectly anti-affine with "wolves" (see: our_indirect_aa_groups)
  • So we want to look up all the members of "wolves", because we're anti-affine with all of them
Our Instance Other Instances
Member in affinity group belonging to anti-affinity group Member in affinity group belonging to anti-affinity group

We care about exactly_one_affinity_group here because if we have an instance which belongs to multiple affinity groups that are anti-affine from each other, we need to consider all those indirect instance members.

In the example above, the sheep "inst1" and "inst2" are not anti-affine from each other, because they both only belong to "exactly one affinity group" in the anti-affinity group. This hits the THEN clause of the CASE statement below, because we don't consider our instance anti-affine from sheep.

If we instead imagine the case of:

  • Affinity group "sweet": inst1, inst2
  • Affinity group "sour": inst1, inst3
  • Affinity group "spicy": inst3, inst4
  • Anti-affinity group "dont-mix-flavors": "sweet", "sour", "spicy"

If we now try to place "inst1", it belongs to both group "sweet" and group "sour". Here, our_indirect_aa_groups.exactly_one_affinity_group is false - so, "inst1" is considered anti-affine with all other groups, including ones it belongs to. This hits the ELSE TRUE clause below, because "inst1" is anti-affine to all indirect groups members here.

@smklein smklein requested review from hawkw and gjcolombo February 28, 2025 23:02
Copy link
Member

@hawkw hawkw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's a lot more of this PR i still need to get through, but here are a few notes from the first couple files...i haven't actually gotten to the meat yet.

Comment on lines +262 to +267
#[derive(Queryable, Insertable, Clone, Debug, Selectable)]
#[diesel(table_name = anti_affinity_group_affinity_membership)]
pub struct AntiAffinityGroupAffinityMembership {
pub anti_affinity_group_id: DbTypedUuid<AntiAffinityGroupKind>,
pub affinity_group_id: DbTypedUuid<AffinityGroupKind>,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely a fan of making this a separate table, rather than changing the existing table to have nullable instance and anti-affinity group ID fields.

Comment on lines +269 to +279
impl AntiAffinityGroupAffinityMembership {
pub fn new(
anti_affinity_group_id: AntiAffinityGroupUuid,
affinity_group_id: AffinityGroupUuid,
) -> Self {
Self {
anti_affinity_group_id: anti_affinity_group_id.into(),
affinity_group_id: affinity_group_id.into(),
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is maybe a bit goofy of me, but for stuff like this where there's a clear directional relationship (the anti-affinity group is a member of the affinity group),i kinda wonder whether we ought to have a method on AntiAffinityGroupUuid be the constructor, so you can write

let membership = anti_affinity_group_id.is_member_of(affinity_group_id);

and have that relationship be clearly stated at the call site? On the other hand, this might just be annoying and deviate from the convention for constructors for no real reason. It's the type of thing I would have reached for in my misspent youth[^1] which makes me kind of leery of this suggestion...

[^1] i.e. back when i wrote Scala

dropshot::PaginationOrder::Descending => false,
};

let mut query = QueryBuilder::new()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpicky: is it worth, perhaps, factoring the raw query builder out from the code that actually runs it, so we can have an expectorate test for it?

Comment on lines +415 to +429
.sql(
"SELECT id,label FROM (
SELECT instance_id as id, 'instance' as label
FROM anti_affinity_group_instance_membership
WHERE group_id = ",
)
.param()
.bind::<diesel::sql_types::Uuid, _>(authz_anti_affinity_group.id())
.sql(
"
UNION
SELECT affinity_group_id as id, 'affinity_group' as label
FROM anti_affinity_group_affinity_membership
WHERE anti_affinity_group_id = ",
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this use of union plus a label to zip together two tables is quite cute, it kinda makes me want to rewrite some code in my webhooks branch...

Comment on lines +463 to +474
use external::AntiAffinityGroupMember as Member;
match label.as_str() {
"affinity_group" => Ok(Member::AffinityGroup(
AffinityGroupUuid::from_untyped_uuid(id),
)),
"instance" => Ok(Member::Instance(
InstanceUuid::from_untyped_uuid(id),
)),
other => Err(external::Error::internal_error(&format!(
"Unexpected label from database query: {other}"
))),
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is kind of a silly idea, but: could we avoid the "other" case if we defined a SQL enum for these labels? not sure if it's actually worth the effort to do that, and having a type that doesn't actually appear in any tables is kinda weird, so take it or leave it.

Comment on lines +855 to +860
let err = err.clone();
use db::schema::anti_affinity_group::dsl as anti_affinity_group_dsl;
use db::schema::affinity_group::dsl as affinity_group_dsl;
use db::schema::affinity_group_instance_membership::dsl as a_instance_membership_dsl;
use db::schema::anti_affinity_group_affinity_membership::dsl as aa_affinity_membership_dsl;
use db::schema::sled_resource_vmm::dsl as resource_dsl;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

turbo nit, sorry: i usually expect all value-level code in a block to be below all the imports in that block, so i found the let err = err.clone(); above the imports a bit easy to miss. i might prefer:

Suggested change
let err = err.clone();
use db::schema::anti_affinity_group::dsl as anti_affinity_group_dsl;
use db::schema::affinity_group::dsl as affinity_group_dsl;
use db::schema::affinity_group_instance_membership::dsl as a_instance_membership_dsl;
use db::schema::anti_affinity_group_affinity_membership::dsl as aa_affinity_membership_dsl;
use db::schema::sled_resource_vmm::dsl as resource_dsl;
use db::schema::anti_affinity_group::dsl as anti_affinity_group_dsl;
use db::schema::affinity_group::dsl as affinity_group_dsl;
use db::schema::affinity_group_instance_membership::dsl as a_instance_membership_dsl;
use db::schema::anti_affinity_group_affinity_membership::dsl as aa_affinity_membership_dsl;
use db::schema::sled_resource_vmm::dsl as resource_dsl;
let err = err.clone();

on the other hand, i will be the first to admit that i'm probably being obnoxious about this, so feel free to disregard me.

Comment on lines +863 to +868
// Check that the anti-affinity group exists
anti_affinity_group_dsl::anti_affinity_group
.filter(anti_affinity_group_dsl::time_deleted.is_null())
.filter(anti_affinity_group_dsl::id.eq(authz_anti_affinity_group.id()))
.select(anti_affinity_group_dsl::id)
.first_async::<uuid::Uuid>(&conn)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this instead be implemented using the existing DatastoreCollection machinery, which IIUC is mostly for dealing with this sort of thing?

Comment on lines +104 to +107
SELECT anti_affinity_group_id AS group_id,instance_id
FROM affinity_group_instance_membership
JOIN our_indirect_aa_groups
ON affinity_group_instance_membership.group_id = our_indirect_aa_groups.affinity_group_id
Copy link
Contributor

@gjcolombo gjcolombo Mar 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm probably completely missing something here, but I'm not quite following how this subquery works. If I may propose a slightly different example, suppose we have

  • affinity group Outcasts contains instances Janis and Damian
  • affinity group Plastics contains instances Regina, Gretchen, and Karen
  • anti-affinity group Dislikes contains affinity groups Outcasts and Plastics (but no instances)

Suppose I'm starting instance Janis. Then (IIUC):

  • our_indirect_aa_groups will contain one row: (Dislikes, Outcasts, TRUE); the other membership, (Dislikes, Plastics), is excluded by this subquery's WHERE clause
  • other_indirect_aa_instances_via_instances will be empty

Next, the query for other_indirect_aa_instances_via_groups will end up SELECTing from

affinity group instance membership
    JOIN our_indirect_aa_groups
    ON affinity_group_instance_membership.group_id = our_indirect_aa_groups.affinity_group_id

The rows of affinity_group_instance_membership are

Outcasts, Janis
Outcasts, Damian
Plastics, Regina
Plastics, Gretchen
Plastics, Karen

And the rows of our_indirect_aa_groups are

Dislikes, Outcasts, TRUE

So the SELECT on the joined table will produce two rows:

Outcasts, Janis
Outcasts, Damian

This seems like the inverse of what we wanted, since this is the subquery that needs to add Regina, Gretchen, and Karen to the anti-affine instance set. I think my brain is expecting the right side of the join to refer to a list of the other cliques affinity groups that are members of the anti-affinity groups in which our instance is transitively a member.

What am I overlooking here?

@smklein
Copy link
Collaborator Author

smklein commented Mar 18, 2025

So, in my attempt to follow-up on this, I:

  • Discussed the issues with membership subtlety with @gjcolombo
  • Wrote up our determinations in https://github.com/oxidecomputer/rfd/pull/869
  • Discussed that with @ahl
  • Determined that allowing this "anti-affinity group which might contain affinity groups" API could prevent live upgrade from proceeding

So:

  • For now, I'm going to close this PR
  • I've left the notes on "how to go about implementing this" in RFD 522, if we decide we need it later

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants