Files

316 lines
6.2 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/indexer"
"github.com/sjzar/chatlog/pkg/util"
)
type bleveIndexable interface {
ListTalkers(ctx context.Context) ([]string, error)
IterateMessages(ctx context.Context, talkers []string, fn func(*model.Message) error) error
}
func (r *Repository) initIndex() error {
if r.indexPath == "" {
return nil
}
idx, err := indexer.Open(r.indexPath)
if err != nil {
return err
}
r.index = idx
r.indexCtx, r.indexCancel = context.WithCancel(context.Background())
go func() {
ready, err := r.ensureIndex(r.indexCtx)
if err != nil && !errors.Is(err, context.Canceled) {
log.Warn().Err(err).Msg("ensure bleve index failed")
return
}
if ready {
log.Info().Msg("bleve index ready")
}
}()
return nil
}
func (r *Repository) ensureIndex(ctx context.Context) (bool, error) {
if r.index == nil {
return false, nil
}
if ctx == nil {
ctx = context.Background()
}
versionMatched, err := r.index.EnsureVersion()
if err != nil {
return false, err
}
if !versionMatched {
r.indexMu.Lock()
r.indexStatus.Ready = false
r.indexStatus.Progress = 0
r.indexFingerprint = ""
r.indexMu.Unlock()
}
fp, err := r.ds.GetDatasetFingerprint(ctx)
if err != nil {
return false, err
}
if strings.TrimSpace(fp) == "" {
return false, fmt.Errorf("dataset fingerprint is empty")
}
r.indexMu.Lock()
if r.indexFingerprint == fp && r.indexStatus.Ready && !r.indexStatus.InProgress {
status := r.indexStatus
r.indexMu.Unlock()
if status.Progress < 1 {
r.updateIndexProgress(1)
}
return true, nil
}
if r.indexStatus.InProgress {
r.indexMu.Unlock()
return false, nil
}
r.indexStatus.InProgress = true
r.indexStatus.Ready = false
r.indexStatus.Progress = 0
r.indexStatus.LastStartedAt = time.Now()
r.indexStatus.LastError = ""
r.indexMu.Unlock()
err = r.rebuildIndex(ctx, fp)
if err != nil {
if err == context.Canceled || errors.Is(err, context.Canceled) {
r.indexMu.Lock()
r.indexStatus.InProgress = false
r.indexMu.Unlock()
return false, err
}
r.indexMu.Lock()
r.indexStatus.InProgress = false
r.indexStatus.LastError = err.Error()
r.indexMu.Unlock()
return false, err
}
r.indexMu.Lock()
r.indexFingerprint = fp
r.indexStatus.InProgress = false
r.indexStatus.Ready = true
r.indexStatus.Progress = 1
r.indexStatus.LastCompletedAt = time.Now()
r.indexMu.Unlock()
return true, nil
}
func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
indexable, ok := r.ds.(bleveIndexable)
if !ok {
return fmt.Errorf("datasource does not support bleve indexing")
}
if err := r.index.Reset(); err != nil {
return err
}
if _, err := r.index.EnsureVersion(); err != nil {
return err
}
talkers, err := indexable.ListTalkers(ctx)
if err != nil {
return err
}
if len(talkers) == 0 {
if err := r.index.UpdateFingerprint(fp); err != nil {
return err
}
return r.index.UpdateLastBuilt(time.Now())
}
sort.Strings(talkers)
batch := make([]*model.Message, 0, 512)
flush := func() error {
if len(batch) == 0 {
return nil
}
if err := r.index.IndexMessages(batch); err != nil {
return err
}
batch = batch[:0]
return nil
}
total := float64(len(talkers))
for i, talker := range talkers {
if err := ctx.Err(); err != nil {
return err
}
handler := func(msg *model.Message) error {
if msg == nil {
return nil
}
batch = append(batch, msg)
if len(batch) >= cap(batch) {
return flush()
}
return nil
}
if err := indexable.IterateMessages(ctx, []string{talker}, handler); err != nil {
return err
}
if err := flush(); err != nil {
return err
}
r.updateIndexProgress(float64(i+1) / total)
}
if err := r.index.UpdateFingerprint(fp); err != nil {
return err
}
if err := r.index.UpdateLastBuilt(time.Now()); err != nil {
return err
}
return nil
}
func (r *Repository) updateIndexProgress(progress float64) {
if r.index == nil {
return
}
if progress < 0 {
progress = 0
}
if progress > 1 {
progress = 1
}
r.indexMu.Lock()
r.indexStatus.Progress = progress
r.indexMu.Unlock()
}
func (r *Repository) indexStatusSnapshot() *model.SearchIndexStatus {
if r.index == nil {
return nil
}
r.indexMu.Lock()
status := r.indexStatus
r.indexMu.Unlock()
copied := status
return &copied
}
func (r *Repository) searchMessagesWithIndex(ctx context.Context, req *model.SearchRequest) (*model.SearchResponse, error) {
makeEmpty := func() *model.SearchResponse {
return &model.SearchResponse{
Total: 0,
Hits: []*model.SearchHit{},
DurationMs: 0,
Limit: req.Limit,
Offset: req.Offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
Index: r.indexStatusSnapshot(),
}
}
if strings.TrimSpace(req.Query) == "" {
return makeEmpty(), nil
}
if r.index == nil {
return makeEmpty(), nil
}
ready, err := r.ensureIndex(ctx)
if err != nil {
return nil, err
}
if !ready {
return makeEmpty(), nil
}
talkers := util.Str2List(req.Talker, ",")
senders := util.Str2List(req.Sender, ",")
startUnix := int64(0)
if !req.Start.IsZero() {
startUnix = req.Start.Unix()
}
endUnix := int64(0)
if !req.End.IsZero() {
endUnix = req.End.Unix()
}
if startUnix > 0 && endUnix > 0 && endUnix < startUnix {
startUnix, endUnix = endUnix, startUnix
}
begin := time.Now()
hits, total, err := r.index.Search(req, talkers, senders, startUnix, endUnix, req.Offset, req.Limit)
if err != nil {
return nil, err
}
mapped := make([]*model.SearchHit, 0, len(hits))
for _, hit := range hits {
if hit == nil || hit.Message == nil {
continue
}
mapped = append(mapped, &model.SearchHit{
Message: hit.Message,
Snippet: hit.Snippet,
Score: hit.Score,
})
}
resp := &model.SearchResponse{
Total: total,
Hits: mapped,
DurationMs: time.Since(begin).Milliseconds(),
Limit: req.Limit,
Offset: req.Offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
Index: r.indexStatusSnapshot(),
}
return resp, nil
}