Skip to content

Commit aa83af2

Browse files
committed
Speed up database evictor by using chunking
1 parent f11bcbc commit aa83af2

File tree

1 file changed

+54
-31
lines changed

1 file changed

+54
-31
lines changed

src/Database/DatabaseEvictStrategy.php

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ class DatabaseEvictStrategy extends AbstractEvictStrategy
2121
protected DatabaseStore $cacheStore;
2222

2323
protected int $deletedRecords = 0;
24+
/**
25+
* @var int
26+
* @deprecated Because this tool does not actually free table spaces, there is no need to track the amount of space freed from eviction.
27+
*/
2428
protected int $deletedRecordSizes = 0;
2529

2630
protected float $elapsedTime = 0;
@@ -46,7 +50,6 @@ public function execute(): void
4650
{
4751
// read the cache config and set up targets
4852
$this->deletedRecords = 0;
49-
$this->deletedRecordSizes = 0;
5053
$this->elapsedTime = 0;
5154

5255
// we use a memory-efficient way of deleting items.
@@ -62,32 +65,34 @@ public function execute(): void
6265
Partyline::info("Found $itemCount records; processing...");
6366

6467
// create a progress bar to display our progress
65-
/** @var ProgressBar $progressBar */
66-
$progressBar = $this->output->createProgressBar();
67-
$progressBar->setMaxSteps($itemCount);
68-
foreach ($this->yieldCacheTableItems() as $cacheItem) {
69-
// read record details
70-
$currentActualKey = $cacheItem->key;
71-
$currentExpiration = $cacheItem->expiration;
72-
// currently timestamps are 32-bit, so are 4 bytes
73-
$estimatedBytes = (int) ($cacheItem->key_bytes + $cacheItem->value_bytes + 4);
74-
$progressBar->advance();
75-
76-
if (time() < $currentExpiration) {
77-
// not expired yet
78-
continue;
68+
$progressBar = $this->output->createProgressBar($itemCount);
69+
foreach ($this->yieldCacheTableChunks() as $chunk) {
70+
$progressBar->advance(count($chunk));
71+
72+
// identify the keys of the records that are potentially expired
73+
$currentTimestamp = time();
74+
$possibleExpiredKeys = [];
75+
foreach ($chunk as $cacheItem) {
76+
$currentActualKey = $cacheItem->key;
77+
$currentExpiration = $cacheItem->expiration;
78+
if ($currentTimestamp < $currentExpiration) {
79+
// not expired yet
80+
continue;
81+
}
82+
// item expired; put to the deletion queue
83+
$possibleExpiredKeys[] = $currentActualKey;
7984
}
80-
// item expired; try to issue a delete command to it
85+
86+
// try to issue a delete command to the database
8187
// this respects any potential new value written to the db while we were checking other things
8288
$rowsAffected = $this->dbConn
8389
->table($this->dbTable)
84-
->where('key', '=', $currentActualKey)
85-
->where('expiration', '=', $currentExpiration)
90+
->whereIn('key', $possibleExpiredKeys)
91+
->where('expiration', '<=', $currentTimestamp)
8692
->delete();
8793
if ($rowsAffected) {
88-
// item really expired with no new updates
89-
$this->deletedRecords += 1;
90-
$this->deletedRecordSizes += $estimatedBytes;
94+
// items really expired with no new updates
95+
$this->deletedRecords += $rowsAffected;
9196
}
9297
}
9398

@@ -98,47 +103,65 @@ public function execute(): void
98103
// all is done; print some stats
99104
$endUnix = microtime(true);
100105
$this->elapsedTime = $endUnix - $startUnix;
101-
// generate a human readable file size
102-
$readableFileSize = $this->bytesToHuman($this->deletedRecordSizes);
106+
// note: the database evictor does not help reclaim free table spaces, so no need to print file size information
103107
Partyline::info("Took {$this->elapsedTime} seconds.");
104-
Partyline::info("Removed {$this->deletedRecords} expired cache records. Estimated total size: $readableFileSize");
108+
Partyline::info("Removed {$this->deletedRecords} expired cache records.");
105109
Partyline::info("Note: no free space reclaimed; reclaiming free space should be done manually!");
106110
}
107111

108112
/**
109113
* Yields the next item from the cache table that belongs to this cache.
110114
*
111115
* This method will return the actual key (with the cache prefix if exists) of the entry.
116+
* @deprecated This method is deprecated in favor of chunked deletion. Currently, it fetches and yields nothing.
112117
* @return \Generator<mixed, object, mixed, void>
113118
*/
114119
protected function yieldCacheTableItems(): \Generator
120+
{
121+
yield new \stdClass();
122+
}
123+
124+
/**
125+
* Yields the next chunk of many items from the cache table that belongs to this cache.
126+
*
127+
* This method will return the actual keys (with the cache prefix if exists) of the entries.
128+
* @return \Generator<mixed, array, mixed, void>
129+
*/
130+
protected function yieldCacheTableChunks(): \Generator
115131
{
116132
// there might be a prefix for the cache store!
117133
$cachePrefix = $this->cachePrefix;
118134
// initialize the key to be just the cache prefix as the "zero string".
119135
$currentActualKey = $cachePrefix;
120136
$prefixLength = strlen($cachePrefix);
137+
// the cache table uses (MySQL) utf8mb4 collation (4 bytes) for its key column with max length 256
138+
// we estimate this should result in max allocation of about $chunkCount * 4 * 256 bytes throughout the eviction
139+
// remember to avoid excessive chunk sizes so that full-table locking is less likely to occur
140+
$chunkCount = 100;
121141
// loop until no more items
122142
while (true) {
123143
// find the next key
124144
// note: different SQL flavors have different interpretations of LIKE, so we use SUBSTRING instead.
125145
// with SUBSTRING, we are clear we want a case-sensitive match, and we might potentially get collation-correct matching
126-
$record = $this->dbConn
146+
$recordsList = $this->dbConn
127147
->table($this->dbTable)
128-
->select(['key', 'expiration', DB::raw('LENGTH(`key`) AS key_bytes'), DB::raw('LENGTH(value) AS value_bytes')])
148+
->select(['key', 'expiration'])
129149
->where('key', '>', $currentActualKey)
130150
->where(DB::raw("SUBSTRING(`key`, 1, $prefixLength)"), '=', $cachePrefix)
131151
// PostgreSQL: if no sorting specified, then will ignore primary key index/ordering, which breaks the intended workflow
132152
->orderBy('key')
133-
->limit(1)
134-
->first();
135-
if (!$record) {
153+
->limit($chunkCount)
154+
->get();
155+
if ($recordsList->isEmpty()) {
136156
// nothing more to get
137157
break;
138158
}
139159

140-
yield $record;
141-
$currentActualKey = $record->key;
160+
$theChunk = $recordsList->all();
161+
yield $theChunk;
162+
// find the last element, and set it to be the current key to continue table-walking
163+
$lastItem = end($theChunk);
164+
$currentActualKey = $lastItem->key;
142165
}
143166
// loop exit handled inside while loop
144167
}

0 commit comments

Comments
 (0)