(9/5) [nexus] Allow anti-affinity members to be affinity groups#7572
(9/5) [nexus] Allow anti-affinity members to be affinity groups#7572smklein wants to merge 27 commits intovmm-reduce-contentionfrom
Conversation
| })?; | ||
| } | ||
|
|
||
| // If this affinity group is a member in other anti-affinity |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
This is tested by anti_affinity_group_membership_list_extended
| dropshot::PaginationOrder::Descending => false, | ||
| }; | ||
|
|
||
| let mut query = QueryBuilder::new() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I'm also experimenting with replacing the paginated macro with some utilities, closer to this implementation, in #7717
There was a problem hiding this comment.
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.
| ).param().sql(" <= sled.reservoir_size | ||
| ), | ||
| our_aa_groups AS ( | ||
| our_a_groups AS ( |
There was a problem hiding this comment.
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 ( |
There was a problem hiding this comment.
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"
| WHERE instance_id = ").param().sql(" | ||
| ), | ||
| other_aa_instances AS ( | ||
| other_direct_aa_instances AS ( |
There was a problem hiding this comment.
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 |
| ON anti_affinity_group_instance_membership.group_id = our_direct_aa_groups.group_id | ||
| WHERE instance_id != ").param().sql(" | ||
| ), | ||
| our_indirect_aa_groups AS ( |
There was a problem hiding this comment.
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 ( |
There was a problem hiding this comment.
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 ( |
There was a problem hiding this comment.
... 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 ( |
There was a problem hiding this comment.
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.
hawkw
left a comment
There was a problem hiding this comment.
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.
| #[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>, | ||
| } |
There was a problem hiding this comment.
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.
| 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(), | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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?
| .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 = ", | ||
| ) |
There was a problem hiding this comment.
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...
| 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}" | ||
| ))), | ||
| } |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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:
| 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.
| // 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) |
There was a problem hiding this comment.
Could this instead be implemented using the existing DatastoreCollection machinery, which IIUC is mostly for dealing with this sort of thing?
| 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 |
There was a problem hiding this comment.
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_groupswill contain one row:(Dislikes, Outcasts, TRUE); the other membership,(Dislikes, Plastics), is excluded by this subquery's WHERE clauseother_indirect_aa_instances_via_instanceswill 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_idThe 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?
|
So, in my attempt to follow-up on this, I:
So:
|
Expands group membership for anti-affinity groups to allow both:
... 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