5 - Cache anagram results, keyed on the dictionary contents

This commit is contained in:
myrmidex 2026-08-23 21:46:14 +02:00
parent 24fa771d39
commit b11bcc3f0b
3 changed files with 127 additions and 15 deletions

View file

@ -3,13 +3,15 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\FindAnagramWordsRequest; use App\Http\Requests\FindAnagramWordsRequest;
use App\Services\AnagramSearch;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator;
use Illuminate\View\View; use Illuminate\View\View;
use Lvl0\AnagramFinder\Core\Matcher;
class AnagramController extends Controller class AnagramController extends Controller
{ {
public function __construct(private readonly AnagramSearch $search) {}
public function index(): View public function index(): View
{ {
return view('index'); return view('index');
@ -17,16 +19,30 @@ public function index(): View
public function find(FindAnagramWordsRequest $request): RedirectResponse public function find(FindAnagramWordsRequest $request): RedirectResponse
{ {
$anagram = $request->get('anagram'); return redirect()->route('results', $request->validated('anagram'));
return redirect()->route('results', $anagram);
} }
public function results(Request $request) public function results(string $anagram): View|RedirectResponse
{ {
$anagram = $request->anagram; // The URL segment bypasses the form request, so it gets the same rules.
$matches = Matcher::findWords($anagram); $anagram = strtolower($anagram);
return view('index', ['anagram' => $anagram, 'matches' => $matches]); $validator = Validator::make(
['anagram' => $anagram],
(new FindAnagramWordsRequest)->rules(),
(new FindAnagramWordsRequest)->messages(),
);
if ($validator->fails()) {
return redirect()
->route('home')
->withErrors($validator)
->withInput(['anagram' => $anagram]);
}
return view('index', [
'anagram' => $anagram,
'matches' => $this->search->find($anagram),
]);
} }
} }

View file

@ -7,23 +7,35 @@
class FindAnagramWordsRequest extends FormRequest class FindAnagramWordsRequest extends FormRequest
{ {
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool public function authorize(): bool
{ {
return true; return true;
} }
/** /**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string> * @return array<string, ValidationRule|array<mixed>|string>
*/ */
public function rules(): array public function rules(): array
{ {
return [ return [
// 'anagram' => ['required', 'string', 'alpha:ascii', 'min:2', 'max:24'],
]; ];
} }
public function messages(): array
{
return [
'anagram.alpha' => 'Letters only — no digits, spaces or punctuation.',
'anagram.max' => 'That is longer than any word in the dictionary.',
];
}
// The dictionary is lowercase and Matcher::check compares raw characters,
// so an uppercase anagram would silently return nothing.
protected function prepareForValidation(): void
{
$this->merge([
'anagram' => strtolower((string) $this->input('anagram', '')),
]);
}
} }

View file

@ -0,0 +1,84 @@
<?php
namespace App\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Lvl0\AnagramFinder\Core\Matcher;
/**
* Caches anagram lookups. A search scans the whole dictionary 466k words
* and the result is fixed for a given (letters, minLength, dictionary), so it
* only ever needs computing once.
*/
class AnagramSearch
{
private const DICTIONARY = __DIR__ . '/../../vendor/anagram-finder/core/assets/words.txt';
public function find(string $anagram, int $minLength = 3): Collection
{
$key = $this->key($anagram, $minLength);
// Cache a plain array: a serialised Collection comes back as
// __PHP_Incomplete_Class through the database store.
$words = Cache::rememberForever(
$key,
fn () => Matcher::findWords($this->normalise($anagram), $minLength)
->values()
->all(),
);
return new Collection($words);
}
/**
* Entries live forever; the dictionary hash in the key is the invalidation
* mechanism. Replacing words.txt makes every old key unreachable rather
* than stale, so nothing has to be flushed by hand.
*/
private function key(string $anagram, int $minLength): string
{
return sprintf(
'anagram:%s:%d:%s',
$this->dictionaryHash(),
$minLength,
$this->normalise($anagram),
);
}
/**
* Sorted letters, so "eamstoxil" and "latoximse" share one entry the same
* bag of letters is the same query.
*/
private function normalise(string $anagram): string
{
$letters = str_split(strtolower($anagram));
sort($letters);
return implode('', $letters);
}
/**
* Content-addressed, not mtime: the dictionary ships inside the image, so
* its mtime changes on every rebuild even when the words do not.
*
* Hashing 4.6MB per request would undo the saving, so the hash is itself
* cached under a key made of the cheap stat values that change whenever the
* file is replaced.
*/
private function dictionaryHash(): string
{
$path = self::DICTIONARY;
if (! is_file($path)) {
return 'nodict';
}
$stat = filemtime($path) . ':' . filesize($path);
return Cache::rememberForever(
"anagram:dict:{$stat}",
fn () => hash_file('xxh128', $path),
);
}
}