Repository navigation
Conversation
|
/retest |
|
@gerrod3 What do you think? Can I get a review? |
gerrod3
left a comment
There was a problem hiding this comment.
Round 1 of reviews. This is honestly quite unreviewable in its current state. The AI comments are a nightmare and make references to docs and comments I don't have access to. The commits are not logically structured either. I would probably have ordered them something like:
- The initial adding of the database-alias and db-router
- Fixing management commands and other models (GenericReleation)
- Adding the database domain migration command
- CI work and tests
I would like to set expectations now that this will require major changes and many iterations before we are close to a state that might be mergeable.
| CROSS_PLANE_RECONCILIATION_GRACE_MINUTES = 60 | ||
|
|
||
| # KI-11: how long, in days, a confirmed-orphaned cross-plane row is kept (logged/alerted on every | ||
| # sweep) before the reconciliation sweep deletes it outright. 0 disables purging entirely -- | ||
| # orphans are only ever logged, never deleted, which is the safe default. | ||
| CROSS_PLANE_RECONCILIATION_PURGE_AFTER_DAYS = 0 |
There was a problem hiding this comment.
Why do we need these two different settings?
There was a problem hiding this comment.
The first one makes sure we don't "flag" objects in "flight" as orphans. For example in the case of a migration of an active domain to a new DB, we need some grace period before we start flagging as orphans
And than how regularly we purge is a different setting, the 2x can be very different values. Multiple days vs multiple hours. Makes sense to be 2 phased in a way.
| _DOMAIN_WALK_MAX_DEPTH = 2 | ||
|
|
||
|
|
||
| def _resolve_domain_id(value, _depth=0, _seen=None): |
There was a problem hiding this comment.
Do we really need this? Yeah it's probably the safest way to get the domain of the object, but I would expect that get_domain would always return the correct domain that the object is in. Maybe it doesn't matter since creating GenericRelationships typically never happen in a hot path
There was a problem hiding this comment.
I see it more as a protection for the future, just playing safe.
|
Rebase this please |
ed36dcc to
2ad60ec
Compare
rebased. tests re-running, had to fix few things, seems okay for now |
35804e0 to
6c71cca
Compare
124b368 to
1c14902
Compare
gerrod3
left a comment
There was a problem hiding this comment.
After more review I am not convinced that this is a good feature to add to Pulp. There are too many unknowns with how this could break and will potentially saddle us with a larger burden of maintenance then we are capable to provide. I would strongly suggest taking another look at running multiple Pulp instances to solve your problem.
If that is still not possible, here are some changes I would like to see.
|
|
||
| instance = hints.get("instance") | ||
| if instance is not None: | ||
| if "pulp_domain_id" in instance.__dict__: |
There was a problem hiding this comment.
We should probably add a comment for why we are looking at __dict__ instead of directly accessing it.
| def is_multi_db_routing_active(): | ||
| return any(isinstance(r, PulpDomainRouter) for r in django_router.routers) |
There was a problem hiding this comment.
I feel we should cache this calculation.
There was a problem hiding this comment.
I think it's already a very cheap calculation, with djanngo_router.routers already being a cached property. An any/isinstance over something that should already be a very short list is a sub-microsecond as is.
Happy to add caching if you really feel it will help
| from pulpcore.app.db_router import is_multi_db_routing_active | ||
|
|
||
| if not is_multi_db_routing_active(): | ||
| return super().filter(*args, **kwargs) |
There was a problem hiding this comment.
Instead of checking this every call, we should just swap these methods in the init if the router is selected.
There was a problem hiding this comment.
Wouldn't we call it more often if we move it to the init?
| def _ensure_default_domain(sender, **kwargs): | ||
| if kwargs.get("using", "default") != "default": | ||
| return | ||
| table_names = connection.introspection.table_names() |
There was a problem hiding this comment.
This is wrong, with multi-db we need to ensure that every domain on a separate DB has that exact domain object in their DB. We might also need to ensure that each DB also has a copy of the default domain.
There was a problem hiding this comment.
I believe that what you are looking for is handled already by _ensure_domains_replicated + reconcile_domains_to_alias()
| return _current_domain.get() or get_default_domain() | ||
|
|
||
|
|
||
| def get_domain_pk(): |
There was a problem hiding this comment.
The handling here is incorrect. Let's take an example of a Repository:
The Repository model has a ForeignKey (pulp_domain) to Domain, with a default value set to this function. This ensures when a Repository is created it gets its pulp-domain set to the Domain that call was initiated from. With multi-db this would currently fail since the current_domain would live in the default database and wouldn't exist in that Domain's database, creating an invalid lookup.
There was a problem hiding this comment.
Why wouldn't the domain exist in the domains database?
A domain has to be already migrated for us to start sending queries to the new db.
And domain_sync handles the sync part.
| with transaction.atomic(): | ||
| content_guard, _created = ContentRedirectContentGuard.objects.get_or_create( | ||
| with transaction.atomic(using=alias): | ||
| content_guard, _created = ContentRedirectContentGuard.objects.using( |
There was a problem hiding this comment.
This would fail without a default domain in the other DB.
There was a problem hiding this comment.
I think it's already covered.
_ensure_domains_replicated runs right before this hook in ready()
reconcile_domains_to_alias() always includes default in its desired set regardless of which alias it's reconciling
So by the time this runs, defaults row is already there?
| if is_multi_db_routing_active(): | ||
| from pulpcore.app.role_util import on_any_model_post_delete | ||
|
|
||
| post_delete.connect( | ||
| on_any_model_post_delete, dispatch_uid="cleanup_cross_plane_roles_post_delete" |
There was a problem hiding this comment.
Is this needed? Are UserRoles even cleanedup currently?
There was a problem hiding this comment.
Django`s deletion collector will treat a GenericRelation like any other reverse relation for cascade purposes and delete the UserRoles when you delete a Repository.
And the current django collector will bypass our domain route, therefore failing silently and leaving orphaned rows behind.
There was a problem hiding this comment.
I think we shouldn't allow cross DB generic relationships in this initial approach. Instead we should do:
- Make Task's CreatedResource.save() a no-op when multi-db is enabled
- Don't allow ObjectRolePermissionBackend or DefaultAccessPolicy to be set when multi-db is enabled so that UserRole & GroupRole objects are not used/created
CreatedResource really isn't needed anymore now that we have Task.output, so this isn't that big of a lost. For the RBAC stuff, if services need to use the role based per object we can think of removing User & Group from the control pane and just duplicating the needed users/groups across the DBs. They don't change as often as UserRole/GroupRoles do so I believe there would be less potential for errors this way. But I think that should be done in a future change if necessary.
There was a problem hiding this comment.
Hm, Task.output? I am missing something I guess
Ripping all of this away, when we already have the code?
There was a problem hiding this comment.
I'm going to comment on the whole of this commit adding the migrate commands here:
I believe this whole piece should be moved to a separate PR in the future. We should test out the feature on brand new domains and see what works and what is broken before trying to migrate everything over. I've done some AI review on this PR and most of the bugs, the really bad ones, are from this part. The migration logic is hard, especially for all that you want it to do, so it needs a lot more design and testing that will delay this PR even further if it were to stay.
On the other hand, did you know you can do a pulp-export/import with domains? Have you thought of just using that for the migration?
There was a problem hiding this comment.
Our issues, and the reason behind this PR, is the real need to move some big domains to seperate databases.
The rest of the PR without us being able to quickly move stuff, doesn't help us, because the users will keep seeing performance issues.
So I suggest that we keep moving with the review.
There was a problem hiding this comment.
The idea here is to separate "having the machinery in place", from "turning the crank". Putting the machinery in place affects everyone who uses Pulp - if that machinery has some edge-cases we haven't considered, it breaks WAY more users than just the Hosted use-case. So, one PR makes the changes that will be there even if you never migrate anyone - because those are global. That lets us address any global problems in isolation from the ...physics of trying to move Lots Of Data.
A second PR would be "OK, now that we have the infrastructure, let's turn the crank", and we can work on addressing problems we discover when we do that.
"We need both parts now" is true - but it's not a good reason to combine them into one PR, that will potentially impact everyone who installs Pulp whether they take advantage of this or not.
There was a problem hiding this comment.
While i will remove the migration, so we can move forward quicker.
I believe that the impact is limited just to the hosted-pulp. As this affects only users who are using the multi-db. And try to migrate, etc
It would have been helpful if I knew what the bugs are, so that I can work on these, while maintaining this as a patch on our side for example.
68c7357 to
1853850
Compare
gerrod3
left a comment
There was a problem hiding this comment.
This feels much more manageable now.
| from django.db.models import Q | ||
|
|
||
|
|
||
| class CrossDBQuerySetMixin: |
There was a problem hiding this comment.
Why is this still here? We ain't doing cross-db queries.
There was a problem hiding this comment.
You are right, leftovers, cleaning.
| moving = models.BooleanField( | ||
| default=False, | ||
| help_text="True while this domain's data is being moved between database aliases.", | ||
| ) |
There was a problem hiding this comment.
Should we still keep this field?
There was a problem hiding this comment.
leftovers, cleaning it
| return True | ||
|
|
||
|
|
||
| def is_multi_db_routing_active(): |
There was a problem hiding this comment.
Let's add an LRU cache, max size of 1.
There was a problem hiding this comment.
done
And your next comment about the task/artifact .. did changes there too. Just can't comment on it now.
| for pa in ProfileArtifact.objects.select_related("artifact").filter(task=task): | ||
| data[pa.name] = get_artifact_url(pa.artifact) | ||
| alias = task.pulp_domain.database_alias | ||
| for pa in ProfileArtifact.objects.filter(task=task): |
There was a problem hiding this comment.
I still don't think this is right. We should ensure when a ProfileArtifact is being saved that the associated artifact is saved in the default database to prevent cross db relations.
| post_migrate.connect( | ||
| _ensure_domains_replicated, | ||
| sender=self, | ||
| dispatch_uid="ensure_domains_replicated_identifier", | ||
| ) |
There was a problem hiding this comment.
For all of these post_migrate and post_save hooks let's group them all into a separate function that is only called if the DBRouter is selected.
| return values | ||
|
|
||
|
|
||
| def replicate_domain_save(domain, using=None, attempts=REPLICATION_RETRY_ATTEMPTS): |
There was a problem hiding this comment.
Let's add a comment describing the behavior: "Default changed -> copied to all satellites. Satellite changed -> copied back to default."
There was a problem hiding this comment.
We do all the changes against default, and sync towards the satellites only
| desired_domains = { | ||
| domain.pulp_id: domain | ||
| for domain in Domain.objects.using("default") | ||
| if domain.name == "default" or domain.database_alias == alias | ||
| } |
There was a problem hiding this comment.
| desired_domains = { | |
| domain.pulp_id: domain | |
| for domain in Domain.objects.using("default") | |
| if domain.name == "default" or domain.database_alias == alias | |
| } | |
| desired_domains = Domain.objects.using("default").filter(Q(name="default") | Q(database_alias=alias)) |
There was a problem hiding this comment.
I think this will break the function, as now desired_domains is a dict.
Let me suggest an alternative there, will push it.
| deleted. Only User, Group, Role, and grants with no target object (global or domain-wide) need | ||
| active replication, because they aren't naturally tied to -- and thus routed to -- a single alias. |
There was a problem hiding this comment.
Is this actually true? I'm trying to think of a scenario with a domain role that isn't routed to a single alias. With global roles yes, it could be useful to have them duplicated, but I'm wondering if we should even allow that. My initial thought it to keep it real simple and only duplicate Users & Groups. Even Roles could be moved off the control pane. My understanding is that most large tenants will get their own database that they can have their domains in. So it should be sufficient to just have the Users & Groups duplicated. If a tenant gets so large to have multiple databases maybe then we should worry about duplicated Roles & global User/GroupRoles.
There was a problem hiding this comment.
I agree, did some changes.
1853850 to
5e1c0ef
Compare
gerrod3
left a comment
There was a problem hiding this comment.
I think we have a problem with transactions. The django.db.transaction api doesn't consult any configured db_router. It explicitly requires using=alias in order to apply the transaction to the correct DB. Also, nested transactions need to be within the same alias. If there is cross DB transactions the behavior is not well defined, and I believe we would experience this on repository creation which update a task's reserved_resource_record.
| - global grants (tied to no object or domain) always live on 'default' | ||
| A grant is only ever read back from the same alias it was written to (role_util. | ||
| get_objects_for_user_roles queries 'default' for global grants and qs's own alias for the other | ||
| two), so none of them need a copy anywhere else. Object-level grants are cleaned up automatically |
There was a problem hiding this comment.
This still seems too complicated. We should just have the global grants be per database. You can create a role assignment from any domain if you have permission, but that role assignment must live in the database of that domain.
There was a problem hiding this comment.
According to Astra, the CI is failing because migration 104_delete_label ends up using the db_router which calls get_default_domain which doesn't use the historical domain model and ends up trying to query for fields that don't yet exist. I believe this happens because you haven't used your with_migration_alias context anywhere yet.
There was a problem hiding this comment.
can we rebuild the containers for the tests? I remmeber that you guys were able to rebuild them clean
what do you think would make for a decent solution here? |
12fc706 to
2b45b6e
Compare
44dc1e4 to
cf5a9c1
Compare
gerrod3
left a comment
There was a problem hiding this comment.
Note
Posted by an agent on behalf of @gerrod3.
I reproduced several routing and RBAC regressions at cf5a9c1 that need addressing before merge. The 18 added routing/RBAC tests pass, but nine additional regression cases fail with real PostgreSQL databases in an isolated oci-env environment.
-
Replicate role permission membership changes. Replication currently runs on
post_save, beforeRoleSerializerapplies.permissions.set(). New roles therefore reach satellites without permissions, and removing a permission leaves it active on the satellite. Please handle M2M membership changes as well as model saves. rbac_sync.py -
Honor the database of loaded data-plane instances. Loading a repository with
.using("data_1"), then saving it outside domain context, inserts a duplicate indefault. Loading a repository version the same way and accessing.repositoryraisesRepository.DoesNotExistbecause the related query goes todefault. The router should respect a saved data-plane instance's_state.dbbefore falling back to ambient context, while preserving creation routing and control-plane handling. db_router.py -
Make commit callbacks use the transaction's database. Transactions now use the satellite alias, but
Artifact.delete()still registers its file deletion callback ondefault. I reproduced a satellite rollback that restores the artifact row while its stored file is deleted. Please supply the correctusingalias to this callback and audit the remainingon_commit()calls alongside the transaction changes. content.py -
Route object-level grants and checks to the target object's database. In satellite context, a
core.task_ownergrant is written to the satellite even though the Task lives indefault. Task queryset scoping searches grants indefault, so the owner cannot see the task. Object-level grant creation/removal should follow the target object's database, including API paths that bypassassign_role(). Direct permission checks also need explicit database selection because the backend currently queries grants through ambient routing. This should be a general rule for object-level grants, covering Tasks, TaskSchedules, Domains, and Groups. Domain-wide grants should continue to follow the domain's database alias. role_util.py, backends.py -
Classify Pulp's Group proxy as control-plane. Its label is
core.group, so theauthapp classification misses it. Creating a Group in satellite context writes it todata_1, and the replication handler ignores that save because it did not originate indefault. Please classify the proxy explicitly or use its concrete model for this decision. db_router.py -
Preserve created-resource tracking for targets in
default. The current early return disables tracking for every target whenever the router is enabled. Please retain existing tracking when the target lives indefault. Satellite targets can be omitted to avoid cross-database relationships; that limitation should be explicit because cancellation cleanup currently depends on these records. task.py -
Make satellite default content guards compatible with the Domain FK. Setting
default_content_guardto a satellite guard violates the FK on the Domain row indefault. Replicating the selected guard intodefault, with the same UUID and owning domain, before updating the FK would address this without a cross-database relationship. The replica needs the concrete subclass data and update/delete synchronization. Composite guards also require their child guards and membership records. domain.py, CompositeContentGuard
|
@gerrod Yes i confirmed the bugs and fixed them |
594e0f1 to
b45e3f6
Compare
…ting Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ting Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e routing transaction.atomic() with no using= always manages 'default', regardless of which alias the db_router routes the enclosed writes to. Swap every single-alias call site to the ambient-resolving atomic() helper, including three select_for_update() sites that raise TransactionManagementError outright under a non-default alias. Split the two call sites that mixed a control-plane write (always 'default') with data-plane writes, since no single atomic() can cover both. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
b45e3f6 to
1672e3d
Compare
gerrod3
left a comment
There was a problem hiding this comment.
Note
Posted by an agent on behalf of @gerrod3.
Thanks for working on this. I left inline comments on the issues I found. The main ones:
- Domains that share an alias get processed multiple times by
for_each_domaincallers. - Replicating Permission pks assumes identical pks on every alias, which upgrade history doesn't guarantee.
- Group-based grants on satellites never match because the group IDs are a lazy subquery.
- Domain delete and save replication can leave satellites inconsistent (missing PROTECT checks, no
on_commit, thedefault_content_guardFK). - User saves, including each login's
last_loginupdate, write synchronously to every satellite.
Smaller cleanups: the retry/save helpers are repeated in domain_sync, content_guard_sync and rbac_sync (along with satellite_aliases and _field_values) and could share one implementation. Also, the PR doesn't have a changelog entry in CHANGES/ yet.
| yield domain.database_alias | ||
|
|
||
|
|
||
| def for_each_domain(callback): |
There was a problem hiding this comment.
Note
Posted by an agent on behalf of @gerrod3.
for_each_domain calls the callback once per Domain, but the callers query the whole alias without filtering by pulp_domain. Every domain that shares an alias reprocesses all rows on it.
On a single-DB install with DOMAIN_ENABLED and 5 domains (all on default):
datarepair-2327dry run reports 5x the real remote count.handle-artifact-checksums --reportprints the same data 5 times under different domain headers and retries failed restores 5 times.dump-publications-to-fs(the unfilteredDistribution.objects.using(alias).all()) exports each publication 5 times to the same path.
Either filter by pulp_domain=domain in the callbacks, or iterate distinct aliases instead of domains where the command is alias-wide.
| } | ||
|
|
||
|
|
||
| def _permission_ids(permissions): |
There was a problem hiding this comment.
Note
Posted by an agent on behalf of @gerrod3.
Copying raw Permission pks assumes the auto-increment pks match on every alias. Same plugins doesn't guarantee that, because pks depend on each database's migration history:
defaultwas likely created years ago and upgraded many times, so ContentTypes/Permissions for newer models got appended with higher pks. A satellite created today gets everything in onemigrate, in app order.- Django leaves stale ContentType/Permission rows behind for removed models unless
remove_stale_contenttypesis run, sodefaulthas gaps a fresh satellite won't.
A satellite added to an existing deployment would end up with roles pointing at the wrong permissions (or an FK error). get_objects_for_user_roles also filters satellite queries with a Permission resolved on default, so this turns into wrong grants rather than a loud failure.
The comment in role_util.get_objects_for_user_roles also says this replication is natural-key-based, which doesn't match the code. A cheap fix: build a (app_label, codename) -> pk map per alias once and translate the IDs before writing the through table. If you'd rather keep the assumption, add a check at migrate time that fails loudly when the pks don't match.
| if ( | ||
| GroupRole.objects.using(alias) | ||
| .filter( | ||
| group_id__in=user_obj.groups.values_list("pk", flat=True), |
There was a problem hiding this comment.
Note
Posted by an agent on behalf of @gerrod3.
group_id__in=user_obj.groups.values_list("pk", flat=True) is a lazy subquery, so it runs on the satellite alias, where auth_user_groups is never replicated. Group-based domain and object grants on satellites will never match.
role_util already materializes the group IDs with list(...) first. The same fix is needed here and at lines 38, 81 and 133 (and the group__in=user_obj.groups.all() in get_all_permissions).
| # Check for global roles. A grant with neither an object nor a domain lives on | ||
| # whichever alias it was created on (role_util.assign_role), so there's no "target | ||
| # database" to route to here -- ambient routing is correct. | ||
| if user_obj.object_roles.filter( |
There was a problem hiding this comment.
Note
Posted by an agent on behalf of @gerrod3.
Global grant checks seem to read from different databases. user_obj.object_roles should route to default through the User instance's db hint, while the GroupRole.objects query just below and role_util.get_objects_for_user_roles use the ambient alias or qs.db.
So a global role granted from default context passes has_model_perms, but list endpoints for a satellite domain find no grant and return nothing (and the reverse for a grant created under satellite context). The "ambient routing is correct" comment doesn't hold for the object_roles half.
| replicate_domain_save(instance, using=using) | ||
|
|
||
|
|
||
| def on_domain_post_delete(sender, instance, using, **kwargs): |
There was a problem hiding this comment.
Note
Posted by an agent on behalf of @gerrod3.
Two concerns with replicating deletes from here:
- The
PROTECTFKs ondefaultcan't see data stored on the satellite, so a domain that still has repositories ondata_1gets deleted ondefault. The replicated delete then raisesProtectedErrorondata_1, which is only logged, so the satellite keeps orphaned data with no Domain on the control plane.general_delete(or the domain delete check) should look at the domain's own alias before deleting. - Saves and deletes are replicated synchronously in
post_save/post_delete. If thedefaulttransaction rolls back afterwards, the satellite has already changed. Wrapping the replication intransaction.on_commitwould avoid that.
| _replicate_simple_delete(type(role), alias, role.pulp_id, attempts=attempts) | ||
|
|
||
|
|
||
| def ensure_replicated_on_alias(alias): |
There was a problem hiding this comment.
Note
Posted by an agent on behalf of @gerrod3.
ensure_replicated_on_alias(alias) never uses alias. replicate_user/group/role loop over every satellite, with 3 attempts and backoff sleeps each.
Running migrate --database data_1 while data_2 isn't migrated yet retries every User, Group and Role against data_2, about 3s per entity. With 1000 users the migrate stalls for close to an hour. Passing the alias through so the backfill only targets that one satellite would fix it.
| if not updated: | ||
| raise RuntimeError(f"The repository '{self.name}' could not be locked") | ||
| # lock the repository if it was created from within a running task | ||
| task_id = Task.current_id() |
There was a problem hiding this comment.
Note
Posted by an agent on behalf of @gerrod3.
Moving the reserved_resources_record update and its RuntimeError out of the atomic() block means a failed lock no longer rolls back the repository creation. The repository and its initial version stay committed while the error propagates.
I get that Task and the repository can now be on different aliases, so a single transaction can't cover both. But for single-DB installs this is a regression. Nesting atomic(using="default") around the repository's atomic() would keep the old behavior when the aliases match and get close to it when they don't. Publication.delete and RepositoryVersion.delete have the same pattern with CreatedResource.
| replicate_role(role, attempts=REPLICATION_RETRY_ATTEMPTS) | ||
|
|
||
|
|
||
| def on_user_post_save(sender, instance, using, **kwargs): |
There was a problem hiding this comment.
Note
Posted by an agent on behalf of @gerrod3.
Every User save synchronously writes to every satellite. That includes the last_login update on each login (update_last_login calls save(update_fields=["last_login"])), so login latency grows with the number of satellites, and a slow or unavailable satellite delays every login.
post_save receives update_fields, so a small fix is to return early when nothing satellites care about changed, e.g.:
def on_user_post_save(sender, instance, using, update_fields=None, **kwargs):
if update_fields is not None and set(update_fields) <= {"last_login"}:
return
replicate_user(instance, using=using)Related: Roles and Groups get replicated again on post_save and on every m2m_changed event, so _populate_roles and .set() replicate the same role several times. Collecting the changed pks during the transaction and replicating each one once in transaction.on_commit would remove the duplicates.
|
|
||
| def replicate_content_guard_to_default(guard, attempts=REPLICATION_RETRY_ATTEMPTS): | ||
| """ | ||
| Replicate ``guard`` -- resolved to its concrete subclass -- into 'default', recursing into |
There was a problem hiding this comment.
Note
Posted by an agent on behalf of @gerrod3.
Nit: this docstring uses double backticks, and our contributing guidelines ask for docs and comments without them. Single backticks would match the rest of the codebase.
| from pulpcore.app.db_router import is_multi_db_routing_active | ||
|
|
||
| if is_multi_db_routing_active(): | ||
| # content_object is a plain, unmodified GenericForeignKey (intentionally -- see |
There was a problem hiding this comment.
Note
Posted by an agent on behalf of @gerrod3.
Nit: this comment is very long for what the code does. Something like "Skip tracking targets on a satellite alias to avoid a cross-database generic relation" would cover it. The same goes for several other comments and docstrings in this PR (e.g. the rbac_sync module docstring, role_util.assign_role, on_group_permissions_changed). Our contributing guidelines ask to keep comments short and concise.
54d54fe to
54a697a
Compare
- Classify Group proxy model as control-plane in PulpDomainRouter: resolve through a proxy's concrete model before classifying, so a Group created under satellite context routes to (and replicates from) 'default' like other control-plane models. - Honor a persisted instance's own _state.db in PulpDomainRouter before falling back to ambient routing, fixing duplicate inserts on save and DoesNotExist on FK traversal for instances loaded via .using(alias) outside any domain context. - Replicate Role/Group permission M2M membership changes to satellites: add m2m_changed handlers, since .permissions.set()/.add()/ .remove()/.clear() don't fire post_save on the Role/Group row itself. - Pass using= to transaction.on_commit() calls (Artifact's file-delete callback, Repository's version/checkpoint cleanup callbacks), which otherwise default to 'default' regardless of the actual transaction's alias. - Route object-level role grants to their target object's own database instead of ambient context, since the target may be a control-plane model (Task, TaskSchedule, Domain, Group) that always lives in 'default'. Applied consistently across assign_role/remove_role, RolesMixin, NestedRoleSerializer, ObjectRolePermissionBackend, and TaskViewSet's owner-annotation query. - Preserve CreatedResource tracking for targets living in 'default', rather than unconditionally skipping it whenever multi-db routing is active -- task-cancellation cleanup depends on this tracking. - Replicate a satellite ContentGuard into 'default' for default_content_guard, since Domain always lives in 'default' and Postgres has no cross-database foreign keys. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
54a697a to
30cfd7b
Compare
- Avoid reprocessing domains that share a database alias in for_each_domain callers (datarepair-2327/datarepair/ dump-publications-to-fs now filter by pulp_domain; handle-artifact- checksums iterates aliases directly via the new for_each_alias()). - Translate permission IDs by (app_label, codename) during RBAC replication instead of trusting raw pks, which aren't guaranteed to match across aliases. - Materialize group-id lookups before they're embedded in cross-alias RBAC queries, and route global-grant checks through UserRole directly instead of through a related manager that silently forces routing to 'default'. - Re-check every PROTECT relation against a domain's own alias before allowing its delete (Django's native check only sees 'default'), and defer domain save/delete replication until the transaction actually commits. - Replicate a domain's default_content_guard to its own alias before writing that domain's replica there. - Target only the given alias when backfilling RBAC replication instead of silently retrying every other satellite too. - Restore single-DB atomicity for the repository task-lock update and CreatedResource cleanup, now that Task/CreatedResource (control- plane) and the repository/publication/version itself can live on different aliases. - Skip RBAC replication for last_login-only User saves, so login latency no longer scales with the number of satellites. - Trim several overly long comments/docstrings, fix double backticks in content_guard_sync.py, and add the changelog entry this PR was missing. Role/Group permissions still replicate once per post_save plus once per m2m_changed event (.permissions.set() fires both), which the reviewer also flagged as worth deduplicating. Left for a follow-up: the suggested fix (collect changed pks, replicate once via transaction.on_commit) needs its own dedup-tracking state outside Django's on_commit machinery, which has no rollback hook -- a rolled- back transaction would leave that state permanently set, silently blocking replication for that entity afterward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
30cfd7b to
696f087
Compare
Initial PR for the multidb implementation. To test github actions. etc