| |
| |
| package persistence |
|
|
| import ( |
| "bufio" |
| "context" |
| "encoding/json" |
| "fmt" |
| "os" |
| "path/filepath" |
| "regexp" |
| "sort" |
| "strings" |
| "sync" |
| "time" |
|
|
| "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/errors" |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports" |
| ) |
|
|
| |
| type LogIndexEntry struct { |
| RequestID string `json:"request_id"` |
| Filename string `json:"filename"` |
| Timestamp time.Time `json:"timestamp"` |
| Method string `json:"method"` |
| URL string `json:"url"` |
| StatusCode int `json:"status_code"` |
| Size int64 `json:"size"` |
| Offset int64 `json:"offset"` |
| Tags map[string]string `json:"tags"` |
| } |
|
|
| |
| type LogIndex struct { |
| mu sync.RWMutex |
|
|
| |
| byRequestID map[string]*LogIndexEntry |
|
|
| |
| byTimestamp *TimeIndex |
|
|
| |
| byMethod map[string][]*LogIndexEntry |
|
|
| |
| byStatusCode map[int][]*LogIndexEntry |
|
|
| |
| byFile map[string][]*LogIndexEntry |
|
|
| |
| indexPath string |
|
|
| |
| dirty bool |
|
|
| |
| lastPersisted time.Time |
| } |
|
|
| |
| type TimeIndex struct { |
| |
| timestamps []time.Time |
|
|
| |
| entries map[int64][]*LogIndexEntry |
| } |
|
|
| |
| func NewTimeIndex() *TimeIndex { |
| return &TimeIndex{ |
| timestamps: make([]time.Time, 0), |
| entries: make(map[int64][]*LogIndexEntry), |
| } |
| } |
|
|
| |
| func (ti *TimeIndex) Add(entry *LogIndexEntry) { |
| ts := entry.Timestamp.Unix() |
|
|
| |
| if _, exists := ti.entries[ts]; !exists { |
| |
| idx := sort.Search(len(ti.timestamps), func(i int) bool { |
| return ti.timestamps[i].Unix() >= ts |
| }) |
|
|
| if idx < len(ti.timestamps) && ti.timestamps[idx].Unix() == ts { |
| |
| } else { |
| ti.timestamps = append(ti.timestamps, time.Time{}) |
| copy(ti.timestamps[idx+1:], ti.timestamps[idx:]) |
| ti.timestamps[idx] = entry.Timestamp |
| } |
| } |
|
|
| ti.entries[ts] = append(ti.entries[ts], entry) |
| } |
|
|
| |
| func (ti *TimeIndex) QueryRange(start, end time.Time) []*LogIndexEntry { |
| result := make([]*LogIndexEntry, 0) |
|
|
| startIdx := sort.Search(len(ti.timestamps), func(i int) bool { |
| return !ti.timestamps[i].Before(start) |
| }) |
|
|
| endIdx := sort.Search(len(ti.timestamps), func(i int) bool { |
| return ti.timestamps[i].After(end) |
| }) |
|
|
| for i := startIdx; i < endIdx && i < len(ti.timestamps); i++ { |
| ts := ti.timestamps[i].Unix() |
| result = append(result, ti.entries[ts]...) |
| } |
|
|
| return result |
| } |
|
|
| |
| func NewLogIndex(indexPath string) *LogIndex { |
| return &LogIndex{ |
| byRequestID: make(map[string]*LogIndexEntry), |
| byTimestamp: NewTimeIndex(), |
| byMethod: make(map[string][]*LogIndexEntry), |
| byStatusCode: make(map[int][]*LogIndexEntry), |
| byFile: make(map[string][]*LogIndexEntry), |
| indexPath: indexPath, |
| lastPersisted: time.Now(), |
| } |
| } |
|
|
| |
| func LoadLogIndex(indexPath string) (*LogIndex, error) { |
| idx := NewLogIndex(indexPath) |
|
|
| |
| if _, err := os.Stat(indexPath); os.IsNotExist(err) { |
| |
| return idx, nil |
| } |
|
|
| |
| data, err := os.ReadFile(indexPath) |
| if err != nil { |
| return nil, errors.Wrap(errors.InternalError, "failed to read index file", err) |
| } |
|
|
| |
| var entries []*LogIndexEntry |
| if err := json.Unmarshal(data, &entries); err != nil { |
| |
| entries = idx.parseLineDelimitedJSON(data) |
| } |
|
|
| |
| for _, entry := range entries { |
| idx.addEntry(entry) |
| } |
|
|
| return idx, nil |
| } |
|
|
| |
| func (idx *LogIndex) parseLineDelimitedJSON(data []byte) []*LogIndexEntry { |
| entries := make([]*LogIndexEntry, 0) |
| scanner := bufio.NewScanner(strings.NewReader(string(data))) |
|
|
| for scanner.Scan() { |
| line := strings.TrimSpace(scanner.Text()) |
| if line == "" { |
| continue |
| } |
|
|
| var entry LogIndexEntry |
| if err := json.Unmarshal([]byte(line), &entry); err != nil { |
| continue |
| } |
| entries = append(entries, &entry) |
| } |
|
|
| return entries |
| } |
|
|
| |
| func (idx *LogIndex) Add(entry *LogIndexEntry) { |
| idx.mu.Lock() |
| defer idx.mu.Unlock() |
|
|
| idx.addEntry(entry) |
| idx.dirty = true |
| } |
|
|
| |
| func (idx *LogIndex) addEntry(entry *LogIndexEntry) { |
| |
| idx.byRequestID[entry.RequestID] = entry |
|
|
| |
| idx.byTimestamp.Add(entry) |
|
|
| |
| idx.byMethod[entry.Method] = append(idx.byMethod[entry.Method], entry) |
|
|
| |
| idx.byStatusCode[entry.StatusCode] = append(idx.byStatusCode[entry.StatusCode], entry) |
|
|
| |
| idx.byFile[entry.Filename] = append(idx.byFile[entry.Filename], entry) |
| } |
|
|
| |
| func (idx *LogIndex) GetByRequestID(requestID string) (*LogIndexEntry, bool) { |
| idx.mu.RLock() |
| defer idx.mu.RUnlock() |
|
|
| entry, exists := idx.byRequestID[requestID] |
| return entry, exists |
| } |
|
|
| |
| func (idx *LogIndex) QueryByTimeRange(start, end time.Time) []*LogIndexEntry { |
| idx.mu.RLock() |
| defer idx.mu.RUnlock() |
|
|
| return idx.byTimestamp.QueryRange(start, end) |
| } |
|
|
| |
| func (idx *LogIndex) QueryByMethod(method string) []*LogIndexEntry { |
| idx.mu.RLock() |
| defer idx.mu.RUnlock() |
|
|
| return idx.byMethod[method] |
| } |
|
|
| |
| func (idx *LogIndex) QueryByStatusCode(statusCode int) []*LogIndexEntry { |
| idx.mu.RLock() |
| defer idx.mu.RUnlock() |
|
|
| return idx.byStatusCode[statusCode] |
| } |
|
|
| |
| func (idx *LogIndex) QueryByFile(filename string) []*LogIndexEntry { |
| idx.mu.RLock() |
| defer idx.mu.RUnlock() |
|
|
| return idx.byFile[filename] |
| } |
|
|
| |
| func (idx *LogIndex) GetAllEntries() []*LogIndexEntry { |
| idx.mu.RLock() |
| defer idx.mu.RUnlock() |
|
|
| result := make([]*LogIndexEntry, 0, len(idx.byRequestID)) |
| for _, entry := range idx.byRequestID { |
| result = append(result, entry) |
| } |
|
|
| |
| sort.Slice(result, func(i, j int) bool { |
| return result[i].Timestamp.Before(result[j].Timestamp) |
| }) |
|
|
| return result |
| } |
|
|
| |
| func (idx *LogIndex) Persist() error { |
| idx.mu.Lock() |
| defer idx.mu.Unlock() |
|
|
| if !idx.dirty { |
| return nil |
| } |
|
|
| |
| entries := make([]*LogIndexEntry, 0, len(idx.byRequestID)) |
| for _, entry := range idx.byRequestID { |
| entries = append(entries, entry) |
| } |
|
|
| |
| sort.Slice(entries, func(i, j int) bool { |
| return entries[i].Timestamp.Before(entries[j].Timestamp) |
| }) |
|
|
| |
| file, err := os.Create(idx.indexPath) |
| if err != nil { |
| return errors.Wrap(errors.InternalError, "failed to create index file", err) |
| } |
| defer file.Close() |
|
|
| encoder := json.NewEncoder(file) |
| for _, entry := range entries { |
| if err := encoder.Encode(entry); err != nil { |
| return errors.Wrap(errors.InternalError, "failed to encode index entry", err) |
| } |
| } |
|
|
| idx.dirty = false |
| idx.lastPersisted = time.Now() |
|
|
| return nil |
| } |
|
|
| |
| func (idx *LogIndex) Remove(requestID string) bool { |
| idx.mu.Lock() |
| defer idx.mu.Unlock() |
|
|
| entry, exists := idx.byRequestID[requestID] |
| if !exists { |
| return false |
| } |
|
|
| delete(idx.byRequestID, requestID) |
|
|
| |
| |
| |
|
|
| _ = entry |
| idx.dirty = true |
| return true |
| } |
|
|
| |
| func (idx *LogIndex) Size() int { |
| idx.mu.RLock() |
| defer idx.mu.RUnlock() |
|
|
| return len(idx.byRequestID) |
| } |
|
|
| |
| func (idx *LogIndex) IsDirty() bool { |
| idx.mu.RLock() |
| defer idx.mu.RUnlock() |
|
|
| return idx.dirty |
| } |
|
|
| |
| type IndexedLogRepository struct { |
| *LogRepository |
| index *LogIndex |
| indexPath string |
| logDir string |
| mu sync.RWMutex |
| } |
|
|
| |
| func NewIndexedLogRepository(logDir string, cfg interface{}) (*IndexedLogRepository, error) { |
| indexPath := filepath.Join(logDir, ".log_index.json") |
|
|
| |
| index, err := LoadLogIndex(indexPath) |
| if err != nil { |
| |
| index = NewLogIndex(indexPath) |
| } |
|
|
| |
| baseRepo := &LogRepository{ |
| logDir: logDir, |
| } |
|
|
| return &IndexedLogRepository{ |
| LogRepository: baseRepo, |
| index: index, |
| indexPath: indexPath, |
| logDir: logDir, |
| }, nil |
| } |
|
|
| |
| func (r *IndexedLogRepository) GetRequestLogByID(ctx context.Context, requestID string) ([]byte, error) { |
| r.mu.RLock() |
| defer r.mu.RUnlock() |
|
|
| |
| if entry, exists := r.index.GetByRequestID(requestID); exists { |
| |
| return r.readLogAtOffset(entry.Filename, entry.Offset, entry.Size) |
| } |
|
|
| |
| return r.LogRepository.GetRequestLogByID(ctx, requestID) |
| } |
|
|
| |
| func (r *IndexedLogRepository) readLogAtOffset(filename string, offset, size int64) ([]byte, error) { |
| filePath := filepath.Join(r.logDir, filename) |
|
|
| file, err := os.Open(filePath) |
| if err != nil { |
| return nil, errors.Wrap(errors.InternalError, "failed to open log file", err) |
| } |
| defer file.Close() |
|
|
| |
| if _, err := file.Seek(offset, 0); err != nil { |
| return nil, errors.Wrap(errors.InternalError, "failed to seek in log file", err) |
| } |
|
|
| |
| data := make([]byte, size) |
| if _, err := file.Read(data); err != nil { |
| return nil, errors.Wrap(errors.InternalError, "failed to read log entry", err) |
| } |
|
|
| return data, nil |
| } |
|
|
| |
| func (r *IndexedLogRepository) IndexLogFile(filename string) error { |
| r.mu.Lock() |
| defer r.mu.Unlock() |
|
|
| filePath := filepath.Join(r.logDir, filename) |
|
|
| file, err := os.Open(filePath) |
| if err != nil { |
| return errors.Wrap(errors.InternalError, "failed to open log file", err) |
| } |
| defer file.Close() |
|
|
| |
| entries, err := r.parseLogFile(filename, file) |
| if err != nil { |
| return err |
| } |
|
|
| |
| for _, entry := range entries { |
| r.index.Add(entry) |
| } |
|
|
| return nil |
| } |
|
|
| |
| func (r *IndexedLogRepository) parseLogFile(filename string, file *os.File) ([]*LogIndexEntry, error) { |
| entries := make([]*LogIndexEntry, 0) |
| scanner := bufio.NewScanner(file) |
|
|
| var offset int64 |
| var currentEntry *LogIndexEntry |
|
|
| requestIDRegex := regexp.MustCompile(`-(\w+)\.log$`) |
| requestID := "" |
| if matches := requestIDRegex.FindStringSubmatch(filename); len(matches) > 1 { |
| requestID = matches[1] |
| } |
|
|
| for scanner.Scan() { |
| line := scanner.Text() |
| lineLen := int64(len(line) + 1) |
|
|
| |
| if strings.HasPrefix(line, "=== REQUEST INFO ===") { |
| currentEntry = &LogIndexEntry{ |
| Filename: filename, |
| Offset: offset, |
| RequestID: requestID, |
| Tags: make(map[string]string), |
| } |
| } |
|
|
| if currentEntry != nil { |
| |
| if strings.HasPrefix(line, "Method: ") { |
| currentEntry.Method = strings.TrimPrefix(line, "Method: ") |
| } |
| if strings.HasPrefix(line, "URL: ") { |
| currentEntry.URL = strings.TrimPrefix(line, "URL: ") |
| } |
| if strings.HasPrefix(line, "Timestamp: ") { |
| tsStr := strings.TrimPrefix(line, "Timestamp: ") |
| if ts, err := time.Parse(time.RFC3339Nano, tsStr); err == nil { |
| currentEntry.Timestamp = ts |
| } |
| } |
| if strings.HasPrefix(line, "Status: ") { |
| fmt.Sscanf(strings.TrimPrefix(line, "Status: "), "%d", ¤tEntry.StatusCode) |
| } |
| } |
|
|
| |
| if currentEntry != nil && line == "=== END ===" { |
| currentEntry.Size = offset - currentEntry.Offset |
| entries = append(entries, currentEntry) |
| currentEntry = nil |
| } |
|
|
| offset += lineLen |
| } |
|
|
| return entries, scanner.Err() |
| } |
|
|
| |
| func (r *IndexedLogRepository) RebuildIndex() error { |
| r.mu.Lock() |
| defer r.mu.Unlock() |
|
|
| |
| r.index = NewLogIndex(r.indexPath) |
|
|
| |
| entries, err := os.ReadDir(r.logDir) |
| if err != nil { |
| return errors.Wrap(errors.InternalError, "failed to list log directory", err) |
| } |
|
|
| |
| for _, entry := range entries { |
| if entry.IsDir() { |
| continue |
| } |
|
|
| name := entry.Name() |
| if !strings.HasSuffix(name, ".log") { |
| continue |
| } |
|
|
| if err := r.IndexLogFile(name); err != nil { |
| |
| continue |
| } |
| } |
|
|
| |
| return r.index.Persist() |
| } |
|
|
| |
| func (r *IndexedLogRepository) PersistIndex() error { |
| r.mu.RLock() |
| defer r.mu.RUnlock() |
|
|
| return r.index.Persist() |
| } |
|
|
| |
| func (r *IndexedLogRepository) GetIndexStats() map[string]interface{} { |
| r.mu.RLock() |
| defer r.mu.RUnlock() |
|
|
| return map[string]interface{}{ |
| "total_entries": r.index.Size(), |
| "is_dirty": r.index.IsDirty(), |
| "index_path": r.indexPath, |
| "last_persisted": r.index.lastPersisted, |
| } |
| } |
|
|
| |
| func (r *IndexedLogRepository) QueryLogs( |
| ctx context.Context, |
| start, end time.Time, |
| method string, |
| statusCode int, |
| ) ([]*ports.LogFileInfo, error) { |
| r.mu.RLock() |
| defer r.mu.RUnlock() |
|
|
| var entries []*LogIndexEntry |
|
|
| |
| if !start.IsZero() && !end.IsZero() { |
| entries = r.index.QueryByTimeRange(start, end) |
| } else if method != "" { |
| entries = r.index.QueryByMethod(method) |
| } else if statusCode > 0 { |
| entries = r.index.QueryByStatusCode(statusCode) |
| } else { |
| entries = r.index.GetAllEntries() |
| } |
|
|
| |
| result := make([]*ports.LogFileInfo, 0, len(entries)) |
| for _, entry := range entries { |
| result = append(result, &ports.LogFileInfo{ |
| Name: entry.Filename, |
| Size: entry.Size, |
| Modified: entry.Timestamp, |
| }) |
| } |
|
|
| return result, nil |
| } |
|
|
| |
| var _ ports.LogRepository = (*IndexedLogRepository)(nil) |
|
|