Limit the size of the result caches

The neighbourhood, classic collocator and similar profile caches grew by one
entry per queried word and were never evicted, so a long running worker
eventually exhausted the machine's memory. They are size limited Mojo::Cache
instances now, configurable in the new cache section of the configuration
file.

The neighbourhood cache never hit anyway: the lookup used a comma inside the
hash subscript, i.e. a $; joined multidimensional key, the value was read with
a second key and stored under a third one that did not include
searchBaseVocabFirst at all.

Starting the server with -C now really bypasses the caches. It did not before,
because $opt_C was read in IDS::DeReKoVecs::Read but set in main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I17b801df54137ea81b660229bf789725de5a2b1b
diff --git a/script/derekovecs-server b/script/derekovecs-server
index 1930381..b15341e 100755
--- a/script/derekovecs-server
+++ b/script/derekovecs-server
@@ -4,6 +4,7 @@
 
 use IDS::DeReKoVecs::Read;
 use Mojolicious::Lite;
+use Mojo::Cache;
 use Mojo::JSON qw(decode_json encode_json to_json);
 use base 'Mojolicious::Plugin';
 
@@ -57,12 +58,17 @@
 our $opt_G;
 
 our $mergedEnd=0;
-our %cache;
-our %cccache; # classic collocator cache
-our %spcache; # similar profile cache
 our $opt_p = 5676;
 our $opt_C;
 
+# Neighbourhood results are the biggest objects the server keeps around (one
+# hash plus one vector array per neighbour), so this cache is deliberately
+# small. It is per worker, i.e. the total footprint is
+# workers * max_keys * result size. Set cache => {max_keys => 0} to switch the
+# cache off.
+my $CACHE_MAX_KEYS = app->config->{cache}->{max_keys} // 50;
+my $cache = Mojo::Cache->new(max_keys => $CACHE_MAX_KEYS);
+
 my %marked;
 my $title="";
 my $training_args="";
@@ -82,6 +88,12 @@
 
 getopts('d:D:Gil:p:m:n:M:Ch') or usage();
 
+configure_cache(
+  no_cache             => $opt_C,
+  collocators_max_keys => app->config->{cache}->{collocators_max_keys},
+  profiles_max_keys    => app->config->{cache}->{profiles_max_keys}
+);
+
 if($opt_M) {
   open my $handle, '<:encoding(UTF-8)', $opt_M
     or die "Can't open '$opt_M' for reading: $!";
@@ -359,9 +371,10 @@
           $searchBaseVocabFirst=1;
         }
       }
-      if ($cache{$w.$cutoff.$no_nbs.$sort.$dedupe,$searchBaseVocabFirst}) {
+      my $key = join("\x1c", $w, $cutoff, $no_nbs, $sort, $dedupe, $searchBaseVocabFirst, $nosp);
+      $res = $opt_C ? undef : $cache->get($key);
+      if (defined $res) {
         $c->app->log->info("Getting $w results from cache");
-        $res = $cache{$w.$cutoff.$no_nbs.$sort.$dedupe.$searchBaseVocabFirst}
       } else {
         $c->app->log->info('Looking for neighbours of '.$w);
         if($opt_i) {
@@ -369,7 +382,7 @@
         } else {
           $res = get_neighbours($w, $no_nbs, $sort, $searchBaseVocabFirst, $cutoff, $dedupe, $nosp);
         }
-        $cache{$w.$cutoff.$no_nbs.$sort.$dedupe} = $res;
+        $cache->set($key => $res) unless $opt_C;
       }
       push(@lists, $res->{paradigmatic});
     }