Skip to content

Commit f48abe6

Browse files
Copilotjeffw16
andauthored
Prevent WAN cache invalidation from purging concurrent refreshes (#9)
* Fix WAN cache invalidation race * Correct WAN cache concurrency tests * Bump version from 2.0.0 to 2.0.1 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Jeffrey Wang <jeffw16@users.noreply.github.com>
1 parent e650c30 commit f48abe6

4 files changed

Lines changed: 132 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ of truth.
7373
Definitions are memoized within the request and cached through
7474
`WANObjectCache`, including process caching, hot refresh, stampede protection,
7575
and a cross-server check key. Saves replace the complete definition set in one
76-
database transaction and invalidate both the check key and cached value.
76+
database transaction and invalidate the check key.
7777
Primary-database reads are used when regenerating the cache because replica lag
7878
must not temporarily change title interpretation.
7979

extension.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "NamespaceManager",
3-
"version": "2.0.0",
3+
"version": "2.0.1",
44
"author": [
55
"[https://github.com/mywikis MyWikis LLC]"
66
],

src/NamespaceRepository.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ public function isEmpty(): bool {
113113

114114
public function invalidate(): void {
115115
$this->cache->touchCheckKey( $this->getCheckKey() );
116-
$this->cache->delete( $this->getCacheKey() );
117116
$this->loaded = false;
118117
$this->memoizedDefinitions = [];
119118
}

tests/phpunit/integration/NamespaceRepositoryTest.php

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
use MediaWiki\Extension\NamespaceManager\NamespaceRepository;
66
use MediaWikiIntegrationTestCase;
7+
use Psr\Log\NullLogger;
8+
use Wikimedia\ObjectCache\HashBagOStuff;
9+
use Wikimedia\ObjectCache\WANObjectCache;
10+
use Wikimedia\Rdbms\IConnectionProvider;
711
use Wikimedia\Rdbms\IDatabase;
812

913
/**
@@ -66,6 +70,105 @@ public function testReplaceWithEmptyListDeletesAllRows(): void {
6670
$this->assertTrue( $this->repository->isEmpty() );
6771
}
6872

73+
public function testInvalidationRejectsConcurrentStaleCacheFill(): void {
74+
$cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
75+
$mockTime = 1_700_000_000.0;
76+
$cache->setMockTime( $mockTime );
77+
$repository = $this->newCacheOnlyRepository( $cache );
78+
$key = $cache->makeKey( 'namespacemanager', 'definitions' );
79+
$checkKey = $cache->makeKey( 'namespacemanager', 'definitions', 'check' );
80+
$options = $this->getCacheOptions( $checkKey );
81+
82+
$stale = $cache->getWithSetCallback(
83+
$key,
84+
WANObjectCache::TTL_WEEK,
85+
static function () use ( $repository ): string {
86+
$repository->invalidate();
87+
return 'stale';
88+
},
89+
$options
90+
);
91+
$cache->setMockTime( $mockTime + 20 );
92+
$regenerations = 0;
93+
$fresh = $cache->getWithSetCallback(
94+
$key,
95+
WANObjectCache::TTL_WEEK,
96+
static function () use ( &$regenerations ): string {
97+
$regenerations++;
98+
return 'fresh';
99+
},
100+
$options
101+
);
102+
103+
$this->assertSame( 'stale', $stale );
104+
$this->assertSame( 'fresh', $fresh );
105+
$this->assertSame( 1, $regenerations );
106+
}
107+
108+
public function testInvalidationDoesNotPurgeConcurrentFreshCacheFill(): void {
109+
$cacheBag = new CallbackHashBagOStuff();
110+
$writerCache = new WANObjectCache( [ 'cache' => $cacheBag ] );
111+
$readerCache = new WANObjectCache( [ 'cache' => $cacheBag ] );
112+
$repository = $this->newCacheOnlyRepository( $writerCache );
113+
$key = $readerCache->makeKey( 'namespacemanager', 'definitions' );
114+
$checkKey = $readerCache->makeKey( 'namespacemanager', 'definitions', 'check' );
115+
$options = $this->getCacheOptions( $checkKey );
116+
$regenerations = 0;
117+
118+
$cacheBag->afterNextSet(
119+
static function () use (
120+
$readerCache,
121+
$key,
122+
$options,
123+
&$regenerations
124+
): void {
125+
$readerCache->getWithSetCallback(
126+
$key,
127+
WANObjectCache::TTL_WEEK,
128+
static function () use ( &$regenerations ): string {
129+
$regenerations++;
130+
return 'fresh';
131+
},
132+
$options
133+
);
134+
}
135+
);
136+
137+
$repository->invalidate();
138+
$value = $readerCache->getWithSetCallback(
139+
$key,
140+
WANObjectCache::TTL_WEEK,
141+
static function () use ( &$regenerations ): string {
142+
$regenerations++;
143+
return 'unexpected';
144+
},
145+
$options
146+
);
147+
148+
$this->assertSame( 'fresh', $value );
149+
$this->assertSame( 1, $regenerations );
150+
}
151+
152+
/**
153+
* @return array<string,mixed>
154+
*/
155+
private function getCacheOptions( string $checkKey ): array {
156+
return [
157+
'checkKeys' => [ $checkKey ],
158+
'hotTTR' => WANObjectCache::TTL_HOUR,
159+
'lockTSE' => 30,
160+
'version' => 1,
161+
];
162+
}
163+
164+
private function newCacheOnlyRepository( WANObjectCache $cache ): NamespaceRepository {
165+
return new NamespaceRepository(
166+
$this->createMock( IConnectionProvider::class ),
167+
$cache,
168+
new NullLogger()
169+
);
170+
}
171+
69172
/**
70173
* @return array<int,array<string,mixed>>
71174
*/
@@ -90,3 +193,30 @@ private function getDefinitions(): array {
90193
];
91194
}
92195
}
196+
197+
class CallbackHashBagOStuff extends HashBagOStuff {
198+
199+
/** @var callable|null */
200+
private $afterNextSet;
201+
202+
public function afterNextSet( callable $callback ): void {
203+
$this->afterNextSet = $callback;
204+
}
205+
206+
/**
207+
* @param string $key
208+
* @param mixed $value
209+
* @param int $exptime
210+
* @param int $flags
211+
* @return bool
212+
*/
213+
protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
214+
$result = parent::doSet( $key, $value, $exptime, $flags );
215+
if ( $this->afterNextSet !== null ) {
216+
$callback = $this->afterNextSet;
217+
$this->afterNextSet = null;
218+
$callback();
219+
}
220+
return $result;
221+
}
222+
}

0 commit comments

Comments
 (0)