Cavil-Matcher
view release on metacpan or search on metacpan
lib/Cavil/Matcher/Index.pm view on Meta::CPAN
# SPDX-FileCopyrightText: SUSE LLC
# SPDX-License-Identifier: GPL-2.0-or-later
#
# The Index is the Perl-maximal lifecycle layer over a directory of compiled segments. It is where the
# headline property lives: adding or removing a pattern never rebuilds the whole cache. Adding patterns
# compiles ONE small new segment and appends it to the manifest; removing a pattern only records a
# tombstone. A full recompile ("merge") happens rarely and reads from the authoritative pattern set
# (PostgreSQL stays the source of truth; the compiled index is a derived, disposable cache).
#
# The native engine (Cavil::Matcher::Engine) only walks and resolves; every decision about which
# segments are active and which patterns are tombstoned is made here, in readable Perl.
#
# CONCURRENCY. add_segment/tombstone/merge read the manifest, bump its generation in memory, write
# generation-derived files, then save. Each mutation runs under an exclusive advisory lock (flock on a
# per-index lock file) held around the whole read-modify-save, so concurrent writers on the same host
# serialize instead of clobbering each other's update (which, unlocked, would be last-writer-wins on a
# shared generation-derived filename). The lock is advisory and host-local - which is the right scope,
# since the compiled index is a per-host cache (each host mmaps its own copy). Reads (building a matcher)
# never lock: the manifest swap is atomic (temp+rename), and merge defers deleting the segments it
# retires until the *next* merge (see merge), so a reader that read the old manifest can still mmap the
# files it named. Readers are therefore always safe without a lock.
package Cavil::Matcher::Index;
use strict;
use warnings;
use v5.20;
use feature 'signatures';
no warnings 'experimental::signatures';
use Cavil::Matcher;
use Cavil::Matcher::Manifest;
use Carp 'croak';
use Fcntl ':flock';
use File::Spec;
sub new ($class, %args) {
croak 'dir required' unless defined $args{dir};
my $dir = $args{dir};
mkdir $dir unless -d $dir;
croak "index dir $dir is not a directory" unless -d $dir;
return bless {dir => $dir}, $class;
}
sub dir ($self) { $self->{dir} }
sub _manifest ($self) { Cavil::Matcher::Manifest->new(dir => $self->{dir}) }
sub generation ($self) { $self->_manifest->generation }
# Run a mutation under an exclusive advisory lock on a per-index lock file, held for the whole
# read-modify-save so concurrent writers on the same host serialize. The lock is released when the
# filehandle is closed as the sub returns - including if $code dies, since the handle is destroyed as the
# stack unwinds.
sub _locked ($self, $code) {
my $path = File::Spec->catfile($self->{dir}, '.lock');
open my $lock, '>', $path or croak "cannot open index lock $path: $!"; # uncoverable branch true (I/O error)
flock $lock, LOCK_EX or croak "cannot lock index $path: $!"; # uncoverable branch true (flock failure)
return $code->();
}
# Checksum a segment file with the engine's own hash (no extra dependency), for manifest-level
# integrity on top of the segment's internal CRC.
sub _checksum ($path) {
open my $fh, '<:raw', $path or return ''; # uncoverable branch true (callers verify -r first)
my $ctx = Cavil::Matcher::init_hash(0, 0);
local $/ = \65536;
while (my $chunk = <$fh>) { $ctx->add($chunk) }
close $fh;
return $ctx->hex;
}
# Parse each [id, pattern_text] row to [id, \@tokens], dropping any row that normalizes to an empty token
# list (text that is all punctuation or ignored words). Such a row can never match, so silently keeping
# it would make the manifest's pattern_count claim a pattern was indexed when nothing compilable exists.
# The count and the tombstone-clearing below therefore reflect what actually compiled, not what was asked.
sub _parse_patterns ($patterns) {
my @parsed;
for my $row (@$patterns) {
my $tokens = Cavil::Matcher::parse_tokens($row->[1]);
push @parsed, [$row->[0], $tokens] if @$tokens;
}
return \@parsed;
}
# Compile one segment file from already-parsed [[id, \@tokens], ...] at the given generation. Returns the
# file's basename, or undef on failure.
sub _compile_segment ($self, $parsed, $gen, $basename) {
my $engine = Cavil::Matcher::init_matcher();
$engine->set_generation($gen);
$engine->add_pattern($_->[0], $_->[1]) for @$parsed;
my $path = File::Spec->catfile($self->{dir}, $basename);
return undef unless $engine->dump($path);
return $basename;
}
# Incrementally add patterns as a new delta segment. Existing segment files are never touched.
# $patterns is an arrayref of [id, pattern_text]. Returns the new generation.
sub add_segment ($self, $patterns) {
return $self->generation unless $patterns && @$patterns;
my $parsed = _parse_patterns($patterns);
return $self->generation unless @$parsed; # every row normalized to empty => nothing compilable to add
return $self->_locked(sub {
my $man = $self->_manifest;
my $gen = $man->bump;
my $file = sprintf('seg-%010d.seg', $gen);
$self->_compile_segment($parsed, $gen, $file) or croak "failed to compile segment $file";
my $path = File::Spec->catfile($self->{dir}, $file);
# Fail closed: we just wrote this segment, so we must be able to checksum it. An empty result means
# the file could not be read back (transient I/O or permissions) - store no entry rather than one
# that silently opts out of the manifest-level integrity check. (An empty checksum in a *read*
# manifest is still honoured for backward compatibility; only fresh writes are strict.)
my $checksum = _checksum($path);
croak "failed to checksum new segment $file" unless length $checksum; # uncoverable branch true (I/O race)
# Introducing an id must un-suppress it: clear any tombstone for the ids in this segment, so a
# delete-then-re-add of the same id takes effect immediately rather than staying hidden until the
# next merge clears all tombstones. (Cavil's pattern ids are DB-immutable so reuse should not happen;
# this keeps the lifecycle correct if it ever does, instead of silently relying on that invariant.)
$man->remove_tombstones(map { $_->[0] } @$parsed);
$man->add_segment(file => $file, checksum => $checksum, pattern_count => scalar @$parsed);
$man->save;
return $gen;
});
}
# Record pattern ids as removed. No segment is recompiled; the engine drops these before resolution.
sub tombstone ($self, @ids) {
return $self->generation unless @ids;
# Validate at the public boundary, as add_pattern does: a tombstone id must be an integer in the
# engine's 32-bit id space. Otherwise we would bump the generation and record a tombstone the native
# engine can never apply (it ignores out-of-range ids to avoid uint32 wraparound), and the manifest
# reader would silently drop it on the next load - a dead write.
for my $id (@ids) {
croak sprintf('Cavil::Matcher::Index::tombstone: id %s out of range (must be 1..4294967295)',
defined $id && !ref $id ? $id : '(invalid)')
unless defined $id && !ref $id && $id =~ /^[0-9]+$/ && $id >= 1 && $id <= 4294967295;
}
return $self->_locked(sub {
my $man = $self->_manifest;
$man->bump;
$man->add_tombstones(@ids);
$man->save;
return $man->generation;
});
}
# Rare compaction: rebuild a single base segment from the authoritative pattern set and retire every
# existing segment and tombstone. This is the "merge" step - it is what keeps segment count and the
# tombstone list bounded over time. Reading the full set from the caller (the DB) keeps the engine
# simple and the source of truth in PostgreSQL. Returns the new generation.
sub merge ($self, $patterns) {
my $parsed = _parse_patterns($patterns // []);
return $self->_locked(sub {
my $man = $self->_manifest;
my $gen = $man->bump;
my $file = sprintf('base-%010d.seg', $gen);
$self->_compile_segment($parsed, $gen, $file) or croak "failed to compile base segment $file";
my $path = File::Spec->catfile($self->{dir}, $file);
# Fail closed on a fresh write, as in add_segment: a base we just wrote but cannot checksum must not
# be recorded with an integrity-check-disabling empty checksum.
my $checksum = _checksum($path);
croak "failed to checksum new base segment $file" unless length $checksum; # uncoverable branch true (I/O race)
my @old = map { $_->{file} } @{$man->segments};
$man->set_segments({file => $file, checksum => $checksum, pattern_count => scalar @$parsed});
$man->clear_tombstones;
$man->save;
# Deferred deletion. Readers do not lock: one may have read the *old* manifest just before our swap
# and be about to mmap the segments it named. If we deleted those now, that reader's open() would fail
# and it would build a partial matcher. So we do NOT delete the segments this merge retires (@old);
# we delete only files orphaned by a PREVIOUS merge - on disk but named by neither the manifest we
# just replaced nor the new base. Since compaction is rare, "one merge ago" is an enormous grace
# period next to a reader's read-manifest-then-mmap window, so no reader lock or timer is needed. The
# retired @old become deletable at the next merge. (Not crash-durable - the index is a disposable
# cache rebuilt from PostgreSQL - so recovery from a power loss mid-merge is simply to rebuild.)
my %keep = map { $_ => 1 } (@old, $file);
if (opendir my $dh, $self->{dir}) { # uncoverable branch false (the index dir always exists here)
for my $f (readdir $dh) {
# Sweep two things: segment files no longer referenced (prior-merge orphans), and crash-leftover
# temp files (*.tmp.<pid> from a hard kill between write and rename in dump/save). We hold the
# writer lock and our own temps are already renamed by now, so any temp here is stale - otherwise
# these would accumulate forever (they never match a real segment name, so the checksum/-r guards
# ignore them, but they still litter the dir).
my $orphan_seg = $f =~ /^(?:seg|base)-[0-9]+\.seg\z/ && !$keep{$f};
my $stale_tmp = $f =~ /\.tmp\.[0-9]+\z/;
unlink File::Spec->catfile($self->{dir}, $f) if $orphan_seg || $stale_tmp;
}
closedir $dh;
}
return $gen;
});
}
# Build a ready-to-query engine: attach every active segment (skipping any that are missing, fail their
# manifest checksum, or fail the segment's own validation - never dies), apply the tombstones, and pin
# the generation. A report can record the generation for reproducibility.
sub matcher ($self, %opts) {
my $man = $self->_manifest;
my $engine = Cavil::Matcher::init_matcher();
$engine->set_generation($man->generation);
my $failed = 0;
for my $seg (@{$man->segments}) {
my $path = File::Spec->catfile($self->{dir}, $seg->{file});
unless (-r $path) {
warn "cavil-matcher: segment $seg->{file} missing; skipping\n" unless $opts{quiet};
$failed++;
next;
}
( run in 0.641 second using v1.01-cache-2.11-cpan-800906f7e73 )