Skip to content

Fix a cache invalidation issue with Distributions serving publications indirectly via repository_version - #8008

Merged
dralley merged 1 commit into
pulp:mainfrom
dralley:cache-invalidation-bug
Sep 2, 2026
Merged

Fix a cache invalidation issue with Distributions serving publications indirectly via repository_version#8008
dralley merged 1 commit into
pulp:mainfrom
dralley:cache-invalidation-bug

Conversation

@dralley

@dralley dralley commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Make sure we flush the cache (and update DistributedPublication) for
publications which were indirectly served via
Distribution.repository_version, rather than directly.

closes #7993

Comment thread pulpcore/app/models/publication.py
@dralley
dralley force-pushed the cache-invalidation-bug branch 2 times, most recently from 023e815 to 33db05a Compare August 25, 2026 05:00
@dralley

dralley commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Not quite ready for review yet.

@dralley
dralley force-pushed the cache-invalidation-bug branch 3 times, most recently from 2b2fe3f to a4a3d27 Compare August 31, 2026 03:36
Comment thread pulpcore/app/models/publication.py Outdated
return
DistributedPublication(distribution=self, publication=pub).save()
# Check if this publication is already the active one
already_current = DistributedPublication.objects.filter(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Should this whole block be in a transaction?

@dralley
dralley force-pushed the cache-invalidation-bug branch from a4a3d27 to ca7d995 Compare August 31, 2026 18:24
@dralley
dralley force-pushed the cache-invalidation-bug branch 3 times, most recently from 0f77dea to 9837ef2 Compare September 1, 2026 17:43
@dralley
dralley force-pushed the cache-invalidation-bug branch 2 times, most recently from 9b956cc to 5391c08 Compare September 1, 2026 18:07
@dralley
dralley requested a review from gerrod3 September 1, 2026 18:07
@dralley
dralley marked this pull request as ready for review September 1, 2026 18:07

@gerrod3 gerrod3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new cache invalidation scenario looks correct. I don't know enough about DistributedPublications to comment on the code changes there.

Comment on lines +423 to +424
@pytest.mark.django_db
class TestCacheInvalidationOnDistributionUpdate:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do the cache invalidation tests belong here? Should they be in their own file?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It would be reasonable to separate them but they're also somewhat coupled in the sense that a significant amount of logic is basically shared between the two and the scenarios are basically the same. If you feel strongly we can do that.

@dralley
dralley requested review from gerrod3 and removed request for gerrod3 September 2, 2026 05:22
assert dp_pub1_reactivated.pk == dp_pub1_old.pk, (
"Should be the same DP instance, reactivated"
)
assert dp_pub1_reactivated.expires_at is None, "Should clear expires_at when reactivating"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I did an AI assisted review and it indicated a possible flaw where the reactivation path can lead to two active dps. Can you assert in this test that, after switching back to pub1, we only have one active dp for that distribution?

Comment thread pulpcore/app/models/publication.py Outdated
DistributedPublication.objects.filter(distribution=self, publication=pub)
.select_for_update()
.first()
)

@pedro-psb pedro-psb Sep 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think this is helping with the race condition. If I understand correctly, the goal is ensure the invariant of only 1 active DP.

But the select for update locks a row only if it exists.
So let's say there is no active DP. It can happen the following in a scenario with 2 concurrent writers:

  1. $W_1$ and $W_2$ get past the fast check and see no active DP
  2. $W_1$ starts transcation. Evaluates dp=None (no row to lock on)
  3. $W_2$ starts transcation. Evaluates dp=None (no row to lock on)
  4. $W_1$ creates $DP_1$. Creation hook sees no other active DP to disable and activates itself
  5. $W_2$ creates $DP_2$. Creation hook sees no other active DP to disable ($W_1$ transcation not done yet) and activates itself
  6. $W_1$ ends transaction. $DP_1$ commited
  7. $W_2$ ends transaction. $DP_2$ commited

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A sample test:

@pytest.mark.django_db(transaction=True)
def test_concurrent_set_distributed_publication_leaves_single_winner():
    """Racing ."""

    def race():
        barrier.wait()
        dist.set_distributed_publication()

    for i in range(5):
        pub = pub_factory()
        dist = dist_factory(pub=pub)
        DistributedPublication.objects.filter(distribution=dist).delete()
        barrier = threading.Barrier(2)

        threads = [threading.Thread(target=race) for _ in range(2)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        dps = DistributedPublication.objects.filter(distribution=dist, publication=pub)
        dps_count = dps.filter(expires_at__isnull=True).count()
        assert dps_count == 1, (
            f"[{i=}] exactly one DP must remain active even if both threads inserted a row"
        )

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This was probably not introduced with this changes, so if you just want to not handle it right now (maybe simplify this portion) and focus on the issue about case of serving repository_versions indirectly.

@dralley

dralley commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

I've pushed a new commit based on your suggestions. Claude reasoning:

Is the feedback valid?

Yes — pedro-psb's two points are both real bugs, and they're related:

  1. Concurrent-create race (line 845): select_for_update() on a .filter().first() that returns None locks nothing — you can't lock a row that doesn't exist yet. Two writers could both pass the fast path and both INSERT an active DP. His interleaving walkthrough is correct.

  2. Reactivation → two active DPs (line 240): the reactivation branch used save(skip_hooks=True), so the cleanup AFTER_CREATE hook never ran. Switching back to pub1 reactivated pub1's DP but never expired the still-active pub2 DP → two active DPs for one distribution.

The other comments were already resolved earlier (transaction wrapper, keeping cache tests co-located — dralley said that's fine).

What I changed

  1. publication.py — set_distributed_publication:
  • Lock the Distribution row (Distribution.objects.select_for_update().get(pk=self.pk)) instead of the DP row, so concurrent writers serialize even when no DP exists yet. The second writer re-queries inside the lock and finds the committed active DP.
  • On reactivation, explicitly expire the other active DP(s) for the distribution (since the AFTER_CREATE hook doesn't fire on an update), restoring the single-active invariant.
  1. test_publication_retention.py:
  • Strengthened the reactivation test to assert exactly one active DP remains after switching back (pedro's line-240 request) — this deterministically catches bug Adopts new content_summary layout #2.

On the concurrency test pedro sketched

I implemented it, and it passes (proving the fix), but @pytest.mark.django_db(transaction=True) errors on teardown in pulpcore's unit suite — I confirmed even a trivial transactional test fails the same way. The _populate_access_policies post_migrate receiver requires apps positionally and the transactional teardown flush doesn't supply it; that's why the suite has zero transaction=True tests. So I removed the threaded test rather than ship a CI-reddening teardown error. Enabling transactional unit tests would be a separate infra fix (making that receiver's apps/verbosity args defaulted).

…ions

Distributions serving via `repository_version` were missed by cache invalidation
and DistributedPublication tracking when publications were created or deleted.

Also fixes DistributedPublication to reactivate (clear `expires_at`) when switching
back to a recently-superseded publication, instead of creating a duplicate record.

closes pulp#7993
Assisted-By: Claude Opus 4.6
@dralley
dralley force-pushed the cache-invalidation-bug branch from 98a1f56 to 402694f Compare September 2, 2026 17:06
@dralley
dralley enabled auto-merge (rebase) September 2, 2026 17:06
@dralley
dralley merged commit 41a879a into pulp:main Sep 2, 2026
35 of 40 checks passed
@dralley
dralley deleted the cache-invalidation-bug branch September 2, 2026 18:57
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.

Cache and DistributedPublication not refreshed for repository_version distributions on publication create/delete

3 participants