diff --git a/Model/Entries.php b/Model/Entries.php
index 7e37bb5..cbd320b 100644
--- a/Model/Entries.php
+++ b/Model/Entries.php
@@ -5,17 +5,14 @@
namespace SamJUK\CacheDebounce\Model;
use SamJUK\CacheDebounce\Model\Config;
+use SamJUK\CacheDebounce\Model\Storage\QueueStorageInterface;
use Magento\CacheInvalidate\Model\PurgeCache;
-use Magento\Framework\App\ResourceConnection;
use Psr\Log\LoggerInterface;
class Entries
{
- /** @var string $tableName */
- private $tableName;
-
- /** @var ResourceConnection $resourceConnection */
- private $resourceConnection;
+ /** @var QueueStorageInterface $storage */
+ private $storage;
/** @var PurgeCache $purgeCache */
private $purgeCache;
@@ -29,14 +26,13 @@ class Entries
public function __construct(
Config $config,
PurgeCache $purgeCache,
- ResourceConnection $resourceConnection,
+ QueueStorageInterface $storage,
LoggerInterface $logger
) {
$this->config = $config;
$this->purgeCache = $purgeCache;
- $this->resourceConnection = $resourceConnection;
+ $this->storage = $storage;
$this->logger = $logger;
- $this->tableName = $resourceConnection->getTableName('samjuk_cache_debounce');
}
/**
@@ -44,22 +40,7 @@ public function __construct(
*/
public function add(array $tags) : void
{
- if (!$tags) {
- return;
- }
-
- $this->resourceConnection->getConnection()
- ->insertArray($this->tableName, ['tag'], $tags);
- }
-
- /**
- * Get all tags from the purge queue
- */
- public function get() : array
- {
- $connection = $this->resourceConnection->getConnection();
- $query = $connection->select()->from($this->tableName, 'tag')->distinct();
- return $connection->fetchCol($query);
+ $this->storage->add($tags);
}
/**
@@ -67,12 +48,16 @@ public function get() : array
*/
public function flush() : void
{
- $tags = $this->get();
- if (count($tags) === 0) {
+ $batchId = $this->storage->activeBatch();
+ if ($batchId === '') {
+ $batchId = $this->storage->claim();
+ }
+ if ($batchId === '') {
$this->logger->debug("[CacheDebounce] Nothing to flush");
return;
}
+ $tags = $this->storage->tags($batchId);
$this->logger->debug("[CacheDebounce] Flushing Tags: " . json_encode($tags));
$this->config->setShouldDebouncePurgeRequest(false);
@@ -81,12 +66,20 @@ public function flush() : void
if (!$success) {
$this->logger->error(
- "[CacheDebounce] Purge request failed — leaving tags queued for retry: " . json_encode($tags)
+ "[CacheDebounce] Purge request failed — releasing batch $batchId for retry: "
+ . json_encode($tags)
);
+ $this->storage->release($batchId);
return;
}
- $this->resourceConnection->getConnection()->delete($this->tableName);
+ $this->storage->clear($batchId);
+ } catch (\Throwable $e) {
+ $this->logger->error(
+ "[CacheDebounce] Purge request threw — releasing batch $batchId for retry: " . $e->getMessage()
+ );
+ $this->storage->release($batchId);
+ throw $e;
} finally {
$this->config->setShouldDebouncePurgeRequest(true);
}
diff --git a/Model/Storage/Database.php b/Model/Storage/Database.php
new file mode 100644
index 0000000..dd16af5
--- /dev/null
+++ b/Model/Storage/Database.php
@@ -0,0 +1,126 @@
+resourceConnection = $resourceConnection;
+ $this->tableName = $resourceConnection->getTableName('samjuk_cache_debounce');
+ }
+
+ /**
+ * @inheritDoc
+ *
+ * Dedupes against pending tags at the app level; no PK for this yet.
+ */
+ public function add(array $tags): void
+ {
+ $tags = array_values(array_unique(array_filter($tags, fn ($tag) => $tag !== null && $tag !== '')));
+ if (!$tags) {
+ return;
+ }
+
+ $newTags = array_diff($tags, $this->tags(self::UNCLAIMED_BATCH_ID));
+ if (!$newTags) {
+ return;
+ }
+
+ $rows = [];
+ foreach ($newTags as $tag) {
+ $rows[] = [self::UNCLAIMED_BATCH_ID, $tag];
+ }
+
+ $this->resourceConnection->getConnection()->insertArray(
+ $this->tableName,
+ ['batch_id', 'tag'],
+ $rows,
+ AdapterInterface::INSERT_IGNORE
+ );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function claim(): string
+ {
+ $batchId = bin2hex(random_bytes(16));
+
+ $affectedRows = $this->resourceConnection->getConnection()->update(
+ $this->tableName,
+ ['batch_id' => $batchId],
+ $this->quoteBatchId(self::UNCLAIMED_BATCH_ID)
+ );
+
+ return $affectedRows > 0 ? $batchId : '';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function tags(string $batchId): array
+ {
+ $connection = $this->resourceConnection->getConnection();
+ $query = $connection->select()
+ ->from($this->tableName, 'tag')
+ ->where('batch_id = ?', $batchId);
+
+ return $connection->fetchCol($query);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function clear(string $batchId): void
+ {
+ $this->resourceConnection->getConnection()->delete(
+ $this->tableName,
+ $this->quoteBatchId($batchId)
+ );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function release(string $batchId): void
+ {
+ $this->add($this->tags($batchId));
+ $this->clear($batchId);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function activeBatch(): string
+ {
+ $connection = $this->resourceConnection->getConnection();
+ $query = $connection->select()
+ ->from($this->tableName, ['batch_id'])
+ ->where('batch_id != ?', self::UNCLAIMED_BATCH_ID)
+ ->limit(1);
+
+ $batchId = $connection->fetchOne($query);
+
+ return $batchId !== false ? (string)$batchId : '';
+ }
+
+ private function quoteBatchId(string $batchId): string
+ {
+ return $this->resourceConnection->getConnection()->quoteInto('batch_id = ?', $batchId);
+ }
+}
diff --git a/Model/Storage/QueueStorageInterface.php b/Model/Storage/QueueStorageInterface.php
new file mode 100644
index 0000000..723049a
--- /dev/null
+++ b/Model/Storage/QueueStorageInterface.php
@@ -0,0 +1,39 @@
+moduleDataSetup = $moduleDataSetup;
+ }
+
+ public function apply(): void
+ {
+ // DELETE not TRUNCATE: data patches run inside a transaction.
+ $connection = $this->moduleDataSetup->getConnection();
+ $connection->delete($this->moduleDataSetup->getTable('samjuk_cache_debounce'));
+ }
+
+ public static function getDependencies(): array
+ {
+ return [];
+ }
+
+ public function getAliases(): array
+ {
+ return [];
+ }
+}
diff --git a/Test/Integration/Model/EntriesTest.php b/Test/Integration/Model/EntriesTest.php
index f6b54d2..c607a40 100644
--- a/Test/Integration/Model/EntriesTest.php
+++ b/Test/Integration/Model/EntriesTest.php
@@ -3,7 +3,10 @@
namespace SamJUK\CacheDebounce\Test\Integration\Model;
use PHPUnit\Framework\TestCase;
+use Magento\Framework\App\ResourceConnection;
use Magento\TestFramework\ObjectManager;
+use SamJUK\CacheDebounce\Model\Entries;
+use SamJUK\CacheDebounce\Model\Storage\QueueStorageInterface;
class EntriesTest extends TestCase
{
@@ -11,19 +14,74 @@ class EntriesTest extends TestCase
protected $objectManager;
private $cacheDebouncedEntries;
+ private $storage;
+ private $resourceConnection;
protected function setUp(): void
{
$this->objectManager = ObjectManager::getInstance();
- $this->cacheDebouncedEntries = $this->objectManager->get(\SamJUK\CacheDebounce\Model\Entries::class);
+ $this->cacheDebouncedEntries = $this->objectManager->get(Entries::class);
+ $this->storage = $this->objectManager->get(QueueStorageInterface::class);
+
+ // Independent of the classes under test, so a prior test failing
+ // mid-assertion can never leak rows into the next test.
+ $this->resourceConnection = $this->objectManager->get(ResourceConnection::class);
+ $this->truncateTable();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->truncateTable();
+ }
+
+ private function truncateTable(): void
+ {
+ $connection = $this->resourceConnection->getConnection();
+ $connection->delete($this->resourceConnection->getTableName('samjuk_cache_debounce'));
}
public function testDebouncedEntriesStorage()
{
$this->cacheDebouncedEntries->add(self::CACHE_TAGS);
- $this->assertEquals(self::CACHE_TAGS, $this->cacheDebouncedEntries->get());
- $this->cacheDebouncedEntries->flush();
- $this->assertEquals([], $this->cacheDebouncedEntries->get());
+ $batchId = $this->storage->claim();
+ $this->assertEquals(self::CACHE_TAGS, $this->storage->tags($batchId));
+
+ $this->storage->clear($batchId);
+ $this->assertEquals([], $this->storage->tags($batchId));
+ }
+
+ /**
+ * Regression test for the add-during-purge race: a tag queued after a
+ * batch has been claimed must survive into the *next* batch, not be
+ * silently dropped by the trailing clear() of the batch currently
+ * being purged.
+ */
+ public function testTagAddedWhileBatchIsClaimedSurvivesIntoNextBatch()
+ {
+ $this->cacheDebouncedEntries->add(['cat_c_1']);
+
+ $batchId = $this->storage->claim();
+
+ $this->cacheDebouncedEntries->add(['cat_c_2']);
+
+ $this->assertEquals(['cat_c_1'], $this->storage->tags($batchId));
+
+ $this->storage->clear($batchId);
+
+ $nextBatchId = $this->storage->claim();
+ $this->assertEquals(['cat_c_2'], $this->storage->tags($nextBatchId));
+ }
+
+ public function testSecondConcurrentClaimReturnsEmptyString()
+ {
+ $this->cacheDebouncedEntries->add(self::CACHE_TAGS);
+
+ $batchId = $this->storage->claim();
+ $this->assertNotSame('', $batchId);
+
+ $this->assertSame('', $this->storage->claim());
+
+ $this->storage->clear($batchId);
}
}
diff --git a/Test/Integration/Model/Storage/DatabaseTest.php b/Test/Integration/Model/Storage/DatabaseTest.php
new file mode 100644
index 0000000..f99004b
--- /dev/null
+++ b/Test/Integration/Model/Storage/DatabaseTest.php
@@ -0,0 +1,141 @@
+objectManager = ObjectManager::getInstance();
+ $this->storage = $this->objectManager->create(Database::class);
+
+ // Independent of the class under test, so a prior test failing
+ // mid-assertion can never leak rows into the next test.
+ $this->resourceConnection = $this->objectManager->get(ResourceConnection::class);
+ $this->truncateTable();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->truncateTable();
+ }
+
+ private function truncateTable(): void
+ {
+ $connection = $this->resourceConnection->getConnection();
+ $connection->delete($this->resourceConnection->getTableName('samjuk_cache_debounce'));
+ }
+
+ public function testAddClaimTagsClearRoundTrip()
+ {
+ $this->storage->add(['cat_c_1', 'cat_c_2']);
+
+ $batchId = $this->storage->claim();
+ $this->assertNotSame('', $batchId);
+
+ $this->assertEquals(['cat_c_1', 'cat_c_2'], $this->storage->tags($batchId));
+
+ $this->storage->clear($batchId);
+ $this->assertEquals([], $this->storage->tags($batchId));
+ }
+
+ public function testAddDuringClaimIsNotLostOrDuplicated()
+ {
+ $this->storage->add(['cat_c_1']);
+
+ $batchId = $this->storage->claim();
+
+ // Overlapping tag added after the claim snapshot — must land in the
+ // next batch, not be silently merged into the one already claimed.
+ $this->storage->add(['cat_c_1', 'cat_c_2']);
+
+ $this->assertEquals(['cat_c_1'], $this->storage->tags($batchId));
+
+ $this->storage->clear($batchId);
+
+ $nextBatchId = $this->storage->claim();
+ $this->assertEquals(['cat_c_1', 'cat_c_2'], $this->storage->tags($nextBatchId));
+ }
+
+ public function testAddDoesNotDuplicateAlreadyPendingTags()
+ {
+ $this->storage->add(['cat_c_1']);
+ $this->storage->add(['cat_c_1', 'cat_c_2']);
+
+ $batchId = $this->storage->claim();
+
+ $this->assertEquals(['cat_c_1', 'cat_c_2'], $this->storage->tags($batchId));
+ }
+
+ public function testAddIgnoresNullAndEmptyTags()
+ {
+ $this->storage->add(['cat_c_1', null, '']);
+
+ $batchId = $this->storage->claim();
+
+ $this->assertEquals(['cat_c_1'], $this->storage->tags($batchId));
+ }
+
+ public function testSecondConcurrentClaimReturnsEmptyString()
+ {
+ $this->storage->add(['cat_c_1']);
+
+ $firstBatchId = $this->storage->claim();
+ $this->assertNotSame('', $firstBatchId);
+
+ $this->assertSame('', $this->storage->claim());
+
+ $this->storage->clear($firstBatchId);
+ }
+
+ public function testReleaseMakesTheBatchClaimableAgain()
+ {
+ $this->storage->add(['cat_c_1', 'cat_c_2']);
+ $batchId = $this->storage->claim();
+
+ $this->storage->release($batchId);
+
+ $this->assertEquals([], $this->storage->tags($batchId));
+
+ $nextBatchId = $this->storage->claim();
+ $this->assertEquals(['cat_c_1', 'cat_c_2'], $this->storage->tags($nextBatchId));
+ }
+
+ public function testReleaseMergesCleanlyWithTagsReQueuedWhileClaimed()
+ {
+ $this->storage->add(['cat_c_1']);
+ $batchId = $this->storage->claim();
+
+ // Same tag re-queued while the original batch is still in flight —
+ // release() must not end up with two pending rows for it.
+ $this->storage->add(['cat_c_1']);
+
+ $this->storage->release($batchId);
+
+ $nextBatchId = $this->storage->claim();
+ $this->assertEquals(['cat_c_1'], $this->storage->tags($nextBatchId));
+ }
+
+ public function testActiveBatchTracksClaimedWorkUntilItIsCleared()
+ {
+ $this->assertSame('', $this->storage->activeBatch());
+
+ $this->storage->add(['cat_c_1']);
+ $batchId = $this->storage->claim();
+
+ $this->assertSame($batchId, $this->storage->activeBatch());
+
+ $this->storage->clear($batchId);
+
+ $this->assertSame('', $this->storage->activeBatch());
+ }
+}
diff --git a/Test/Unit/Model/EntriesTest.php b/Test/Unit/Model/EntriesTest.php
index 85a40f7..e7a7167 100644
--- a/Test/Unit/Model/EntriesTest.php
+++ b/Test/Unit/Model/EntriesTest.php
@@ -2,69 +2,91 @@
namespace SamJUK\CacheDebounce\Test\Unit\Model;
+use Magento\CacheInvalidate\Model\PurgeCache;
+use Magento\Framework\App\Config\ScopeConfigInterface;
use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use SamJUK\CacheDebounce\Model\Config as CacheDebounceConfig;
use SamJUK\CacheDebounce\Model\Entries as CacheDebounceEntries;
+use SamJUK\CacheDebounce\Model\Storage\QueueStorageInterface;
class EntriesTest extends TestCase
{
+ private const BATCH_ID = 'batch-123';
private const CACHE_TAGS = ['cat_c_1', 'cat_c_2', 'cat_c_p_1'];
private $cacheDebounceConfig;
private $purgeCacheModel;
- private $connection;
- private $resourceConnection;
+ private $storage;
private $loggerInterface;
private $cacheDebounceEntries;
- private $select;
protected function setUp(): void
{
- $this->select = $this->createMock(\Magento\Framework\DB\Select::class);
- $this->select->method('from')->willReturn($this->select);
- $this->select->method('distinct')->willReturn($this->select);
- $this->connection = $this->createMock(\Magento\Framework\DB\Adapter\AdapterInterface::class);
- $this->connection->method('select')->willReturn($this->select);
- $this->cacheDebounceConfig = $this->createMock(\SamJUK\CacheDebounce\Model\Config::class);
- $this->resourceConnection = $this->createMock(\Magento\Framework\App\ResourceConnection::class);
- $this->resourceConnection->method('getConnection')->willReturn($this->connection);
- $this->purgeCacheModel = $this->createMock(\Magento\CacheInvalidate\Model\PurgeCache::class);
- $this->loggerInterface = $this->createMock(\Psr\Log\LoggerInterface::class);
+ $this->cacheDebounceConfig = $this->createMock(CacheDebounceConfig::class);
+ $this->storage = $this->createMock(QueueStorageInterface::class);
+ $this->purgeCacheModel = $this->createMock(PurgeCache::class);
+ $this->loggerInterface = $this->createMock(LoggerInterface::class);
$this->cacheDebounceEntries = new CacheDebounceEntries(
$this->cacheDebounceConfig,
$this->purgeCacheModel,
- $this->resourceConnection,
+ $this->storage,
$this->loggerInterface
);
}
- public function testFlushTags()
+ public function testFlushDoesNothingWhenNothingIsClaimed()
{
- $this->connection->method('fetchCol')->willReturn([
- self::CACHE_TAGS
- ]);
+ $this->storage->method('activeBatch')->willReturn('');
+ $this->storage->method('claim')->willReturn('');
+
+ $this->purgeCacheModel->expects($this->never())->method('sendPurgeRequest');
+
+ $this->cacheDebounceEntries->flush();
+ }
+
+ public function testFlushPurgesClaimedTagsAndClearsBatch()
+ {
+ $this->storage->method('activeBatch')->willReturn('');
+ $this->storage->method('claim')->willReturn(self::BATCH_ID);
+ $this->storage->method('tags')->with(self::BATCH_ID)->willReturn(self::CACHE_TAGS);
$this->purgeCacheModel->expects($this->once())
->method('sendPurgeRequest')
- ->with([self::CACHE_TAGS])
+ ->with(self::CACHE_TAGS)
->willReturn(true);
- $this->connection->expects($this->once())->method('delete');
+ $this->storage->expects($this->once())->method('clear')->with(self::BATCH_ID);
+
+ $this->cacheDebounceEntries->flush();
+ }
+
+ public function testFlushResumesAnAlreadyClaimedBatchInsteadOfClaimingNew()
+ {
+ $this->storage->method('activeBatch')->willReturn(self::BATCH_ID);
+ $this->storage->method('tags')->with(self::BATCH_ID)->willReturn(self::CACHE_TAGS);
+ $this->storage->expects($this->never())->method('claim');
+
+ $this->purgeCacheModel->method('sendPurgeRequest')->willReturn(true);
+
+ $this->storage->expects($this->once())->method('clear')->with(self::BATCH_ID);
$this->cacheDebounceEntries->flush();
}
- public function testFlushDoesNotClearQueueWhenPurgeRequestFails()
+ public function testFlushReleasesBatchInsteadOfClearingWhenPurgeRequestFails()
{
- $this->connection->method('fetchCol')->willReturn([
- self::CACHE_TAGS
- ]);
+ $this->storage->method('activeBatch')->willReturn('');
+ $this->storage->method('claim')->willReturn(self::BATCH_ID);
+ $this->storage->method('tags')->willReturn(self::CACHE_TAGS);
$this->purgeCacheModel->expects($this->once())
->method('sendPurgeRequest')
- ->with([self::CACHE_TAGS])
+ ->with(self::CACHE_TAGS)
->willReturn(false);
- $this->connection->expects($this->never())->method('delete');
+ $this->storage->expects($this->never())->method('clear');
+ $this->storage->expects($this->once())->method('release')->with(self::BATCH_ID);
$this->loggerInterface->expects($this->once())->method('error');
@@ -73,20 +95,25 @@ public function testFlushDoesNotClearQueueWhenPurgeRequestFails()
public function testFlushResetsDebounceFlagEvenWhenPurgeRequestThrows()
{
- $scopeConfig = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class);
+ $scopeConfig = $this->createMock(ScopeConfigInterface::class);
$scopeConfig->method('isSetFlag')->willReturn(true);
- $config = new \SamJUK\CacheDebounce\Model\Config($scopeConfig);
+ $config = new CacheDebounceConfig($scopeConfig);
- $this->connection->method('fetchCol')->willReturn(self::CACHE_TAGS);
+ $this->storage->method('activeBatch')->willReturn('');
+ $this->storage->method('claim')->willReturn(self::BATCH_ID);
+ $this->storage->method('tags')->willReturn(self::CACHE_TAGS);
$this->purgeCacheModel->method('sendPurgeRequest')->willThrowException(new \RuntimeException('varnish down'));
$entries = new CacheDebounceEntries(
$config,
$this->purgeCacheModel,
- $this->resourceConnection,
+ $this->storage,
$this->loggerInterface
);
+ $this->storage->expects($this->never())->method('clear');
+ $this->storage->expects($this->once())->method('release')->with(self::BATCH_ID);
+
$this->assertTrue($config->shouldDebouncePurgeRequest());
try {
@@ -99,19 +126,17 @@ public function testFlushResetsDebounceFlagEvenWhenPurgeRequestThrows()
$this->assertTrue($config->shouldDebouncePurgeRequest());
}
- public function testAddWithEmptyTagsDoesNotTouchConnection()
+ public function testAddDelegatesToStorage()
{
- $this->connection->expects($this->never())->method('insertArray');
+ $this->storage->expects($this->once())->method('add')->with(self::CACHE_TAGS);
- $this->cacheDebounceEntries->add([]);
+ $this->cacheDebounceEntries->add(self::CACHE_TAGS);
}
- public function testAddWithTagsInsertsThem()
+ public function testAddWithEmptyTagsStillDelegatesToStorage()
{
- $this->connection->expects($this->once())
- ->method('insertArray')
- ->with($this->anything(), ['tag'], self::CACHE_TAGS);
+ $this->storage->expects($this->once())->method('add')->with([]);
- $this->cacheDebounceEntries->add(self::CACHE_TAGS);
+ $this->cacheDebounceEntries->add([]);
}
}
diff --git a/Test/Unit/Model/Storage/DatabaseTest.php b/Test/Unit/Model/Storage/DatabaseTest.php
new file mode 100644
index 0000000..e25fb59
--- /dev/null
+++ b/Test/Unit/Model/Storage/DatabaseTest.php
@@ -0,0 +1,189 @@
+connection = $this->createMock(AdapterInterface::class);
+ $this->connection->method('quoteInto')->willReturnCallback(
+ fn ($text, $value) => str_replace('?', "'$value'", $text)
+ );
+ $this->resourceConnection = $this->createMock(ResourceConnection::class);
+ $this->resourceConnection->method('getConnection')->willReturn($this->connection);
+ $this->resourceConnection->method('getTableName')->willReturn(self::TABLE_NAME);
+
+ $this->storage = new Database($this->resourceConnection);
+ }
+
+ private function stubSelect(): void
+ {
+ $select = $this->createMock(\Magento\Framework\DB\Select::class);
+ $select->method('from')->willReturn($select);
+ $select->method('where')->willReturn($select);
+ $this->connection->method('select')->willReturn($select);
+ }
+
+ public function testAddWithEmptyTagsDoesNotTouchConnection()
+ {
+ $this->connection->expects($this->never())->method('insertArray');
+
+ $this->storage->add([]);
+ }
+
+ public function testAddInsertsTagsAsUnclaimedWithInsertIgnore()
+ {
+ $this->stubSelect();
+ $this->connection->method('fetchCol')->willReturn([]);
+
+ $this->connection->expects($this->once())
+ ->method('insertArray')
+ ->with(
+ self::TABLE_NAME,
+ ['batch_id', 'tag'],
+ [['', 'cat_c_1'], ['', 'cat_c_2']],
+ AdapterInterface::INSERT_IGNORE
+ );
+
+ $this->storage->add(['cat_c_1', 'cat_c_2']);
+ }
+
+ public function testAddFiltersOutNullAndEmptyTags()
+ {
+ $this->stubSelect();
+ $this->connection->method('fetchCol')->willReturn([]);
+
+ $this->connection->expects($this->once())
+ ->method('insertArray')
+ ->with(
+ self::TABLE_NAME,
+ ['batch_id', 'tag'],
+ [['', 'cat_c_1']],
+ AdapterInterface::INSERT_IGNORE
+ );
+
+ $this->storage->add(['cat_c_1', null, '']);
+ }
+
+ public function testAddSkipsTagsAlreadyPending()
+ {
+ $this->stubSelect();
+ $this->connection->method('fetchCol')->willReturn(['cat_c_1']);
+
+ $this->connection->expects($this->once())
+ ->method('insertArray')
+ ->with(
+ self::TABLE_NAME,
+ ['batch_id', 'tag'],
+ [['', 'cat_c_2']],
+ AdapterInterface::INSERT_IGNORE
+ );
+
+ $this->storage->add(['cat_c_1', 'cat_c_2']);
+ }
+
+ public function testAddInsertsNothingWhenEveryTagIsAlreadyPending()
+ {
+ $this->stubSelect();
+ $this->connection->method('fetchCol')->willReturn(['cat_c_1']);
+
+ $this->connection->expects($this->never())->method('insertArray');
+
+ $this->storage->add(['cat_c_1']);
+ }
+
+ public function testClaimReturnsEmptyStringWhenNothingPending()
+ {
+ $this->connection->method('update')->willReturn(0);
+
+ $this->assertSame('', $this->storage->claim());
+ }
+
+ public function testClaimReturnsBatchIdWhenRowsClaimed()
+ {
+ $this->connection->method('update')->willReturn(2);
+
+ $batchId = $this->storage->claim();
+
+ $this->assertNotSame('', $batchId);
+ $this->assertSame(32, strlen($batchId));
+ }
+
+ public function testTagsSelectsScopedToBatchId()
+ {
+ $select = $this->createMock(\Magento\Framework\DB\Select::class);
+ $select->method('from')->willReturn($select);
+ $select->method('where')->with('batch_id = ?', 'batch-123')->willReturn($select);
+ $this->connection->method('select')->willReturn($select);
+ $this->connection->method('fetchCol')->willReturn(['cat_c_1']);
+
+ $this->assertSame(['cat_c_1'], $this->storage->tags('batch-123'));
+ }
+
+ public function testClearDeletesOnlyThatBatch()
+ {
+ $this->connection->expects($this->once())
+ ->method('delete')
+ ->with(self::TABLE_NAME, "batch_id = 'batch-123'");
+
+ $this->storage->clear('batch-123');
+ }
+
+ public function testReleaseReQueuesBatchTagsAsUnclaimedThenClearsTheBatch()
+ {
+ $this->stubSelect();
+ // First call is tags($batchId) reading the claimed batch; second is
+ // add()'s own dedup check against currently-pending tags.
+ $this->connection->method('fetchCol')->willReturnOnConsecutiveCalls(['cat_c_1', 'cat_c_2'], []);
+
+ $this->connection->expects($this->once())
+ ->method('insertArray')
+ ->with(
+ self::TABLE_NAME,
+ ['batch_id', 'tag'],
+ [['', 'cat_c_1'], ['', 'cat_c_2']],
+ AdapterInterface::INSERT_IGNORE
+ );
+ $this->connection->expects($this->once())
+ ->method('delete')
+ ->with(self::TABLE_NAME, "batch_id = 'batch-123'");
+
+ $this->storage->release('batch-123');
+ }
+
+ public function testActiveBatchReturnsEmptyStringWhenNothingClaimed()
+ {
+ $select = $this->createMock(\Magento\Framework\DB\Select::class);
+ $select->method('from')->willReturn($select);
+ $select->method('where')->with('batch_id != ?', '')->willReturn($select);
+ $select->method('limit')->with(1)->willReturn($select);
+ $this->connection->method('select')->willReturn($select);
+ $this->connection->method('fetchOne')->willReturn(false);
+
+ $this->assertSame('', $this->storage->activeBatch());
+ }
+
+ public function testActiveBatchReturnsIdOfAlreadyClaimedBatch()
+ {
+ $select = $this->createMock(\Magento\Framework\DB\Select::class);
+ $select->method('from')->willReturn($select);
+ $select->method('where')->willReturn($select);
+ $select->method('limit')->willReturn($select);
+ $this->connection->method('select')->willReturn($select);
+ $this->connection->method('fetchOne')->willReturn('batch-123');
+
+ $this->assertSame('batch-123', $this->storage->activeBatch());
+ }
+}
diff --git a/etc/db_schema.xml b/etc/db_schema.xml
index 41f0d15..80c6838 100644
--- a/etc/db_schema.xml
+++ b/etc/db_schema.xml
@@ -1,6 +1,9 @@
diff --git a/etc/db_schema_whitelist.json b/etc/db_schema_whitelist.json
new file mode 100644
index 0000000..fcb747e
--- /dev/null
+++ b/etc/db_schema_whitelist.json
@@ -0,0 +1,9 @@
+{
+ "samjuk_cache_debounce": {
+ "column": {
+ "batch_id": true,
+ "tag": true,
+ "created_at": true
+ }
+ }
+}
diff --git a/etc/di.xml b/etc/di.xml
index 36fd265..68f549a 100644
--- a/etc/di.xml
+++ b/etc/di.xml
@@ -1,5 +1,7 @@
+
+