Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
# Changelog
All notable changes to this project will be documented in this file.

## Unreleased — next minor release

### Added

* Opt-in `ParallelOptions.AutoOverlap` protects matches across all chunk boundaries, including keywords longer than a chunk, using one engine snapshot per call.
* `CacheStats.PresetReloadFailures` and `PresetPollFailures` report cumulative refresh failures, excluding cancellation.

### Fixed

* Preset reload waiters respond independently to cancellation. Shared jobs stop when all waiters leave or the instance closes. Snapshot fetching and engine building run outside the state lock; generation checks reject obsolete reloads after writes or invalidation.
* Preset polling reads only the existing version field. Failed refreshes still return search errors and retry on later requests; polling is disabled by default and provides no outage freshness bound.
* Parallel options are copied before normalization. Existing behavior remains the default. No Redis schema change or data migration is required.

## [v1.5.2](https://github.com/skyoo2003/acor/releases/tag/v1.5.2) - 2026-08-09

### Changed
Expand Down
19 changes: 11 additions & 8 deletions api/v1-audit.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ field AhoCorasickArgs.DB int fixed acor.go:262 gave '0-15' as if checked; nothin
field AhoCorasickArgs.Debug bool ok acor.go:271; newLogger switches the default logger to stdout at acor.go:448
field AhoCorasickArgs.DialTimeout time.Duration ok acor.go:280; carried into every topology through universalOptions (client.go:86) and the hand-built ring (client.go:100), so the shared 'all topologies' preamble holds for it
field AhoCorasickArgs.EnableCache bool ok acor.go:283; both documented rejections fire at acor.go:437,503
field AhoCorasickArgs.InvalidationPollInterval time.Duration ok acor.go:354; read only at redis_backed.go:91 and the poller starts only when > 0 (redis_backed.go:117), so 'disabled by default, Preset mode only' is accurate
field AhoCorasickArgs.InvalidationPollInterval time.Duration ok redis_backed.go:372 reads only version and marks stale; the next search reloads. Disabled by default, Preset only; failures mean the interval cannot bound freshness.
field AhoCorasickArgs.Logger Logger ok acor.go:307; a non-nil Logger wins over the default at acor.go:494
field AhoCorasickArgs.MasterName string ok acor.go:254; client.go:27-28 selects the failover client on a non-blank MasterName and client.go:55-57 requires Addrs with it, exactly as documented
field AhoCorasickArgs.MaxRetries int ok acor.go:286; client.go:89,104. -1 disabling retries is go-redis's contract, not this package's
Expand All @@ -51,8 +51,10 @@ field BatchResult.Skipped []string fixed options.go:110 gave only "duplicates in
field CacheStats.Hits uint64 ok stats.go:21; re-verdicted after #206, which landed the one-read-per-call behavior the sentence now describes. FindParallelContext, FindIndexParallelContext and FindManyContext each call loadEngine exactly once (context_ops.go:143,188,111), and hit/miss are recorded only inside loadEngine (v2_ops.go:274,282, redis_backed.go:230,239, engine_memo.go:43,46), so writes, Suggest and Info record nothing. TestCacheStatsCountsOneReadPerCall (stats_test.go:61) pins it
field CacheStats.LastInvalidationLag time.Duration ok stats.go:74; recordInvalidationLag drops only negatives (stats.go:143) per TestCacheStatsDiscardsNegativeLag, and the listener-only modes match invalidation.go:192
field CacheStats.Misses uint64 fixed stats.go:35 claimed a failed Redis fetch is always a miss; true for preset (redis_backed.go:239) and cached V2 (v2_ops.go:282), false for default V2, which fetches at v2_ops.go:260 and only then reaches the counter. Sentence now names the split; TestCacheStatsFailedFetchByMode pins all three modes
field CacheStats.RebuildDuration time.Duration ok stats.go:62; timeRebuild (stats.go:165) wraps build alone — the Redis fetch happens before it at v2_ops.go:260 and the lock is taken before it at engine_memo.go:40, matching both exclusions
field CacheStats.Rebuilds uint64 ok stats.go:50; starts at 1 in Preset per TestCacheStatsPreset (stats_test.go:214), and coalesced misses share one build at engine_memo.go:39-47, which is the documented Misses-Rebuilds gap
field CacheStats.PresetPollFailures uint64 ok redis_backed.go:372 records failed version reads; TestPresetPollVersionOnlyAndRecovery verifies version-only polling, errors and recovery.
field CacheStats.PresetReloadFailures uint64 ok redis_backed.go:297 records once per shared failed job, excluding cancellation; TestPresetReloadFailureSharedAndRetry verifies one failure for two waiting requests.
field CacheStats.RebuildDuration time.Duration ok redis_backed.go:177 times only engine construction in Preset, excluding Redis reads, materialization and publication. Other modes retain their existing timed callbacks.
field CacheStats.Rebuilds uint64 ok redis_backed.go:177 counts every Preset engine build, including rejected generations. Construction starts at one; shared misses coalesce, local writes also rebuild.
field KeywordError.Error error ok options.go:97; carries ErrEmptyKeyword or the write error, batch.go:69,126,141
field KeywordError.Keyword string ok options.go:95; set from the offending keyword, batch.go:68,126,140
field Match.End int ok matches.go:25; exclusive, indexed at matches.go:327 with an m.End >= len bound
Expand Down Expand Up @@ -82,17 +84,18 @@ field OperationError.Err error ok errors.go:77; the wrapped cause, returned by U
field OperationError.Keyword string ok errors.go:73; left empty by newOperationError, which is why Error has the two-branch format at errors.go:83
field OperationError.Op string ok errors.go:71; set from the op argument, errors.go:112
field OperationError.Schema int ok errors.go:75; carries the schema constant passed in, e.g. SchemaV2 at v2_ops.go:114
field ParallelOptions.AutoOverlap bool ok parallel.go:243 extends right by max(Overlap, longest rune length minus one); context_ops.go:170 filters owned starts and preserves chunk order. TestAutoOverlapSerialParity verifies Unicode and long keywords.
field ParallelOptions.Boundary ChunkBoundary ok options.go:68; the zero value is ChunkBoundaryWord (options.go:37), so unset does split on whitespace
field ParallelOptions.ChunkSize int fixed options.go:61 promised a DefaultChunkSize fallback; normalizeParallelOptions (parallel.go:89-104) never sets it and context_ops.go:127,177 reject <= 0 with ErrInvalidChunkSize, so the documented default was an error return. TestParallelOptionsHaveNoImpliedDefaults pins it
field ParallelOptions.Overlap int fixed options.go:71 promised DefaultOverlap; parallel.go:96-102 only clamps negatives, leaving an unset Overlap at 0 and silently missing boundary-straddling keywords. Same test pins it
field ParallelOptions.Workers int ok options.go:58; parallel.go:93-95 substitutes runtime.NumCPU() when <= 0, exactly as documented
field ParallelOptions.Overlap int ok parallel.go:90 copies options and clamps negative overlap to zero. Legacy splitting is unchanged; AutoOverlap treats it as a minimum right extension.
field ParallelOptions.Workers int ok parallel.go:90 copies options and replaces nonpositive Workers with runtime.NumCPU without mutating the caller.
field RedisError.Err error ok errors.go:99; the client error, returned by Unwrap at errors.go:109
field RedisError.Key string ok errors.go:97; the key involved, v2_ops.go:108
field RedisError.Op string ok errors.go:95; the Redis verb, e.g. "HGETALL" at v2_ops.go:108
func Create(args *AhoCorasickArgs) (*AhoCorasick, error) ok acor.go:403 delegates to CreateContext with context.Background, and the documented error cases are the guards at acor.go:420-437 and client.go:47-68
func CreateContext(ctx context.Context, args *AhoCorasickArgs) (*AhoCorasick, error) ok acor.go:418; ctx governs setup only, and the background listener runs on an internal context per acor.go:476
func DefaultMigrationOptions() *MigrationOptions ok schema.go:64 names DryRun=false, KeepOldKeys=false, Progress=nil; the body returns the zero value at schema.go:67, which is exactly those three
func DefaultParallelOptions() *ParallelOptions ok options.go:83 returns exactly the four documented values, and is the only source of them
func DefaultParallelOptions() *ParallelOptions ok options.go:88 supplies CPU workers, 1000 rune chunks, word boundary, overlap 50 and AutoOverlap false.
method (*AhoCorasick) Add(keyword string) (int, error) fixed acor.go:654 listed only "added" and "already exists" for a 0 return; an empty keyword also returns (0, nil) at redis_backed_ops.go:20 and v2_ops.go:81. Case added; TestEmptyKeywordIsNotAnErrorOutsideBatch pins it
method (*AhoCorasick) AddContext(ctx context.Context, keyword string) (int, error) ok context_ops.go:7 forwards to the same ops.add that Add uses (acor.go:701), so ctx reaches Redis in V2 and preset mode; cross-reference to Add's return values added
method (*AhoCorasick) AddMany(keywords []string, opts *BatchOptions) (*BatchResult, error) fixed batch.go:45 said 'duplicate keywords are skipped', which reads as exact duplicates; screening is on the normalized form (batch.go:110-114), so 'Foo' and 'foo' are one keyword on a case-insensitive collection. Also added that a transactional failure returns a nil *BatchResult (batch.go:204,214). TestBatchDuplicatesAreJudgedNormalized pins both
Expand Down Expand Up @@ -132,8 +135,8 @@ method (*AhoCorasick) RollbackToV1() error fixed migration.go:349 named only the
method (*AhoCorasick) SchemaVersion() int ok acor.go:562 returns the stored version with no Redis I/O
method (*AhoCorasick) Suggest(input string) ([]string, error) ok acor.go:710 delegates to ops.suggest; preset mode returns ErrSuggestRequiresRedis at redis_backed_ops.go:160
method (*AhoCorasick) SuggestContext(ctx context.Context, input string) ([]string, error) fixed context_ops.go:49 omitted that preset mode cannot serve it at all - redis_backed_ops.go:160 returns ErrSuggestRequiresRedis, since the local automaton holds no prefix index. Added
method (*AhoCorasick) SuggestIndex(input string) (map[string][]int, error) ok acor.go:716 delegates to ops.suggestIndex; preset mode returns ErrSuggestRequiresRedis at redis_backed_ops.go:164
method (*AhoCorasick) SuggestIndexContext(ctx context.Context, input string) (map[string][]int, error) fixed context_ops.go:57; same omission and same sentinel at redis_backed_ops.go:164
method (*AhoCorasick) SuggestIndex(input string) (map[string][]int, error) ok acor.go:716 delegates to ops.suggestIndex; preset mode returns ErrSuggestRequiresRedis at redis_backed_ops.go:159
method (*AhoCorasick) SuggestIndexContext(ctx context.Context, input string) (map[string][]int, error) fixed context_ops.go:57; same omission and same sentinel at redis_backed_ops.go:159
method (*MigrationResult) Stats() map[string]interface{} fixed schema.go:127 offered 'migration statistics'; it returns 6 of the 13 fields (schema.go:128-135), omitting every outcome field, so a caller cannot tell success from a dry run or a failure by reading the map. Now documented as a projection with the six named
method (*OperationError) Error() string ok errors.go:82; includes op, schema and cause, and adds the keyword only when set
method (*OperationError) Unwrap() error ok errors.go:90 returns Err, so errors.Is and errors.As reach the cause as documented
Expand Down
3 changes: 3 additions & 0 deletions api/v1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ field BatchResult.Skipped []string
field CacheStats.Hits uint64
field CacheStats.LastInvalidationLag time.Duration
field CacheStats.Misses uint64
field CacheStats.PresetPollFailures uint64
field CacheStats.PresetReloadFailures uint64
field CacheStats.RebuildDuration time.Duration
field CacheStats.Rebuilds uint64
field KeywordError.Error error
Expand Down Expand Up @@ -82,6 +84,7 @@ field OperationError.Err error
field OperationError.Keyword string
field OperationError.Op string
field OperationError.Schema int
field ParallelOptions.AutoOverlap bool
field ParallelOptions.Boundary ChunkBoundary
field ParallelOptions.ChunkSize int
field ParallelOptions.Overlap int
Expand Down
5 changes: 5 additions & 0 deletions changes/unreleased/20260905-search-refresh.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Added
body: "Add opt-in automatic parallel boundary protection and cancellation-independent Preset reloads, version-only polling, and refresh failure counters. No data migration is required."
time: 2026-09-05T19:00:00+09:00
custom:
Issue: "243"
35 changes: 32 additions & 3 deletions docs/content/guides/parallel-matching.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,32 @@ weight: 2

# Parallel Matching

For large texts, use parallel matching to leverage multiple goroutines.
For large texts, use parallel matching to scan with multiple goroutines.

## Overview

Parallel matching splits text into chunks and processes them concurrently, significantly improving performance for large inputs.

## Basic Usage

<!-- doccheck -->
```go
matches, err := ac.FindParallel(largeText, &acor.ParallelOptions{
Workers: 4,
ChunkSize: 1000,
AutoOverlap: true,
Boundary: acor.ChunkBoundaryWord,
})
if err != nil {
panic(err)
}
_ = matches
```

## Chunk Boundaries

Chunk boundaries ensure matches aren't split across chunks:
Boundary selection chooses where base chunks end. Enable `AutoOverlap` to protect
matches across every boundary; boundary selection alone cannot guarantee this.

### ChunkBoundaryWord (default)

Expand All @@ -34,6 +39,8 @@ Splits at word boundaries, ideal for natural language text:
```go
opts := &acor.ParallelOptions{
Workers: 4,
ChunkSize: 1000,
AutoOverlap: true,
Boundary: acor.ChunkBoundaryWord,
}
```
Expand All @@ -45,6 +52,8 @@ Splits at line breaks, ideal for log files:
```go
opts := &acor.ParallelOptions{
Workers: 4,
ChunkSize: 1000,
AutoOverlap: true,
Boundary: acor.ChunkBoundaryLine,
}
```
Expand All @@ -56,10 +65,30 @@ Splits at sentence endings, ideal for document processing:
```go
opts := &acor.ParallelOptions{
Workers: 4,
ChunkSize: 1000,
AutoOverlap: true,
Boundary: acor.ChunkBoundarySentence,
}
```

## Automatic Boundary Protection

`AutoOverlap: true` uses the longest keyword in the engine loaded for this call.
Each base chunk owns its starting positions and extends its right search range by
`max(Overlap, longest keyword rune length - 1)`. Matches starting in the extension
belong to the next base chunk and are excluded from the current chunk. This also
finds keywords longer than `ChunkSize`, including Korean and emoji keywords.
No extra Redis query is needed, and all workers use the same dictionary snapshot.

`FindParallel` returns each keyword once, in chunk order and then first-match scan
order within each chunk. That order need not equal serial scan order.
`FindIndexParallel` returns sorted unique rune positions in the original text.

The default `AutoOverlap` is `false`, preserving legacy overlapping chunks.
In that mode, an insufficient `Overlap` can miss boundary matches, and a keyword
longer than a chunk may not fit in any chunk. Options are copied before normalization.
`ChunkSize` must be positive; empty input performs no Redis reads.

## Performance Tuning

### Worker Count
Expand All @@ -83,7 +112,7 @@ Control chunk size with the `ChunkSize` option:
```go
opts := &acor.ParallelOptions{
Workers: 4,
ChunkSize: 10000, // 10KB chunks
ChunkSize: 10000, // 10,000 runes per base chunk
}
```

Expand Down
8 changes: 8 additions & 0 deletions docs/content/guides/preset-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ info, err := ac.Info() // (*AhoCorasickInfo, error)
ac.Flush()
```

## Refresh and Cancellation

Stale reads share a reload but respond independently to request cancellation.
A failed reload returns a search error and is retried by a later request. Optional
version polling helps recover from missed Pub/Sub messages; its interval does not
guarantee freshness during failures. See [invalidation safety](../redis-backed-engine/#invalidation-safety)
for the refresh lifecycle and failure counters.

## Next Steps

- [Redis-Backed Engine](../redis-backed-engine/) - Redis persistence details
Expand Down
24 changes: 20 additions & 4 deletions docs/content/guides/redis-backed-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ Instance A ──Find()──▶ local engine (0 RTT)
- **Writes**: V2 Lua scripts with optimistic locking (up to 3 retries with backoff)
- **Reads**: Local preset-optimized automaton — no Redis I/O
- **Invalidation**: Redis Pub/Sub notifies all instances on mutation
- **Degraded mode**: If reload fails, the last-good engine continues serving reads
- **Reload failures**: The previous engine is retained, but the waiting search returns an error. A later search retries the reload.

## Invalidation Safety

Redis Pub/Sub is best effort. In a multi-instance deployment, set
`InvalidationPollInterval` to bound how long a dropped invalidation can leave a
local preset engine stale:
`InvalidationPollInterval` to recover from dropped invalidations after a successful
version poll and reload:

<!-- doccheck -->
```go
Expand All @@ -55,7 +55,23 @@ _ = args
```

The zero value disables polling. Polling only applies to Preset mode; normal
invalidation still uses Pub/Sub.
invalidation still uses Pub/Sub. Each poll reads only the existing `version` hash
field; a changed version marks the engine stale, and the next search reads the
full snapshot. The interval is not a freshness upper bound: Redis failures, query
latency, and rebuild time can delay recovery. This mode provides eventual refresh,
not strong consistency or a guarantee of fresh results during an outage.

Concurrent stale reads share a reload, while each request independently observes
its own context cancellation. Canceling one waiter leaves the others running.
The instance lifetime owns the job; all waiters leaving or `Close` cancels it.
Redis reads and engine builds run outside the state lock. A generation check
rejects and retries snapshots overtaken by local writes or invalidations.

`CacheStats().PresetReloadFailures` counts failed shared jobs once per job;
`PresetPollFailures` counts failed version polls. Cancellation is excluded.
Both counters are cumulative per instance and remain zero outside Preset mode.
Monitor these counters alongside application search errors; a retained previous
engine does not turn a failed reload into a successful response.

## Quick Start

Expand Down
Loading