From 456dab093ced8780d331a2162d2abcd137c41eef Mon Sep 17 00:00:00 2001 From: Jonathan Bird Date: Mon, 1 Dec 2025 09:59:37 +1000 Subject: [PATCH 1/2] Stache Batch Get Items for Redis/Memcached - Reduce Network Overhead --- src/Stache/Stores/AggregateStore.php | 25 +++++++++++-- src/Stache/Stores/BasicStore.php | 53 ++++++++++++++++++++++++++++ tests/Stache/BasicStoreTest.php | 39 ++++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/src/Stache/Stores/AggregateStore.php b/src/Stache/Stores/AggregateStore.php index 334c0b63835..9e943fb6b66 100644 --- a/src/Stache/Stores/AggregateStore.php +++ b/src/Stache/Stores/AggregateStore.php @@ -53,9 +53,30 @@ public function childDirectory($child) public function getItems($keys) { - return collect($keys)->map(function ($key) { - return $this->getItem($key); + $keys = collect($keys); + + if ($keys->isEmpty()) { + return collect(); + } + + // Group keys by child store for batch fetching + $grouped = $keys->mapWithKeys(function ($key) { + [$store, $id] = explode('::', $key, 2); + + return [$key => compact('store', 'id')]; + })->groupBy('store'); + + // Batch fetch from each child store + $fetched = $grouped->flatMap(function ($items, $store) { + $ids = $items->pluck('id'); + $storeItems = $this->store($store)->getItems($ids); + + // Re-key with full keys (store::id) + return $ids->mapWithKeys(fn ($id, $i) => ["{$store}::{$id}" => $storeItems[$i]]); }); + + // Return in original order + return $keys->map(fn ($key) => $fetched[$key]); } public function getItem($key) diff --git a/src/Stache/Stores/BasicStore.php b/src/Stache/Stores/BasicStore.php index b0af2fd90f4..ac22b47c629 100644 --- a/src/Stache/Stores/BasicStore.php +++ b/src/Stache/Stores/BasicStore.php @@ -15,6 +15,59 @@ public function getItemFilter(SplFileInfo $file) abstract public function makeItemFromFile($path, $contents); + public function getItems($keys) + { + $this->handleFileChanges(); + + $keys = collect($keys); + + if ($keys->isEmpty()) { + return collect(); + } + + // Only use batch fetch for cache drivers that benefit from it (network-based) + if ($this->shouldUseBatchCaching()) { + return $this->getItemsBatched($keys); + } + + return $keys->map(fn ($key) => $this->getItem($key)); + } + + protected function getItemsBatched($keys) + { + // Build a map of cache keys to item keys + $cacheKeyMap = $keys->mapWithKeys(fn ($key) => [$this->getItemCacheKey($key) => $key]); + + // Batch fetch from cache + $cached = Stache::cacheStore()->many($cacheKeyMap->keys()->all()); + + // Process results, fetching any misses from disk + return $keys->map(function ($key) use ($cached) { + $cacheKey = $this->getItemCacheKey($key); + + if ($item = $cached[$cacheKey] ?? null) { + if (method_exists($item, 'syncOriginal')) { + $item->syncOriginal(); + } + + return $item; + } + + // Cache miss - fetch individually (will also cache it) + return $this->getItem($key); + }); + } + + protected function shouldUseBatchCaching(): bool + { + $store = Stache::cacheStore()->getStore(); + + // These drivers benefit from batch operations (network round-trip reduction) + return $store instanceof \Illuminate\Cache\RedisStore + || $store instanceof \Illuminate\Cache\MemcachedStore + || $store instanceof \Illuminate\Cache\DynamoDbStore; + } + public function getItem($key) { $this->handleFileChanges(); diff --git a/tests/Stache/BasicStoreTest.php b/tests/Stache/BasicStoreTest.php index df470c69011..41b6190bdf3 100644 --- a/tests/Stache/BasicStoreTest.php +++ b/tests/Stache/BasicStoreTest.php @@ -53,6 +53,45 @@ public function items_are_different_instances_every_time() $this->assertNotSame($one, $two); } + #[Test] + public function it_gets_multiple_items_by_keys() + { + file_put_contents($this->tempDir.'/foo.yaml', ''); + file_put_contents($this->tempDir.'/bar.yaml', ''); + file_put_contents($this->tempDir.'/baz.yaml', ''); + + $items = $this->store->getItems(['foo', 'bar', 'baz']); + + $this->assertCount(3, $items); + $this->assertEquals('foo', $items[0]->id()); + $this->assertEquals('bar', $items[1]->id()); + $this->assertEquals('baz', $items[2]->id()); + } + + #[Test] + public function it_gets_multiple_items_preserving_order() + { + file_put_contents($this->tempDir.'/foo.yaml', ''); + file_put_contents($this->tempDir.'/bar.yaml', ''); + file_put_contents($this->tempDir.'/baz.yaml', ''); + + // Request in different order than created + $items = $this->store->getItems(['baz', 'foo', 'bar']); + + $this->assertCount(3, $items); + $this->assertEquals('baz', $items[0]->id()); + $this->assertEquals('foo', $items[1]->id()); + $this->assertEquals('bar', $items[2]->id()); + } + + #[Test] + public function it_returns_empty_collection_for_empty_keys() + { + $items = $this->store->getItems([]); + + $this->assertCount(0, $items); + } + #[Test] public function it_gets_an_item_by_path() { From 4ce169e6afc66068bf86aa61287502e7d55766b9 Mon Sep 17 00:00:00 2001 From: Jonathan Bird Date: Mon, 1 Dec 2025 10:20:11 +1000 Subject: [PATCH 2/2] Add tests for AggregateStore::getItems() --- tests/Stache/AggregateStoreTest.php | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/Stache/AggregateStoreTest.php b/tests/Stache/AggregateStoreTest.php index 73bda2e7032..6448742bf74 100644 --- a/tests/Stache/AggregateStoreTest.php +++ b/tests/Stache/AggregateStoreTest.php @@ -34,6 +34,54 @@ public function it_gets_and_sets_child_stores() $this->assertInstanceOf(ChildStore::class, $childOne); $this->assertEquals(['one' => $childOne, 'two' => $childTwo], $this->store->stores()->all()); } + + #[Test] + public function it_gets_items_from_multiple_child_stores() + { + $this->store->store('one')->setItems([ + 'a' => ['id' => 'a', 'title' => 'Item A'], + 'b' => ['id' => 'b', 'title' => 'Item B'], + ]); + + $this->store->store('two')->setItems([ + 'c' => ['id' => 'c', 'title' => 'Item C'], + 'd' => ['id' => 'd', 'title' => 'Item D'], + ]); + + $items = $this->store->getItems(['one::a', 'two::c', 'one::b']); + + $this->assertCount(3, $items); + $this->assertEquals('a', $items[0]['id']); + $this->assertEquals('c', $items[1]['id']); + $this->assertEquals('b', $items[2]['id']); + } + + #[Test] + public function it_gets_items_preserving_order_across_stores() + { + $this->store->store('one')->setItems([ + 'a' => ['id' => 'a'], + 'b' => ['id' => 'b'], + ]); + + $this->store->store('two')->setItems([ + 'c' => ['id' => 'c'], + 'd' => ['id' => 'd'], + ]); + + // Request in mixed order + $items = $this->store->getItems(['two::d', 'one::a', 'two::c', 'one::b']); + + $this->assertEquals(['d', 'a', 'c', 'b'], $items->pluck('id')->all()); + } + + #[Test] + public function it_returns_empty_collection_for_empty_keys() + { + $items = $this->store->getItems([]); + + $this->assertCount(0, $items); + } } class TestAggregateStore extends AggregateStore @@ -53,4 +101,20 @@ public function discoverStores() class TestChildStore extends ChildStore { + protected $items = []; + + public function setItems(array $items) + { + $this->items = $items; + } + + public function getItem($key) + { + return $this->items[$key] ?? null; + } + + public function getItems($keys) + { + return collect($keys)->map(fn ($key) => $this->getItem($key)); + } }