Spaces:
Runtime error
Runtime error
| // Package processor provides the main processing logic for GDELT timestamps. | |
| // It uses parallel processing with bounded concurrency, structural validation, | |
| // and existential validation via HEAD probing. | |
| package processor | |
| import ( | |
| "context" | |
| "crypto/sha256" | |
| "encoding/hex" | |
| "fmt" | |
| "io" | |
| "net/url" | |
| "strings" | |
| "sync" | |
| "time" | |
| "gdelt-engine/internal/constants" | |
| "gdelt-engine/internal/downloader" | |
| "gdelt-engine/internal/parser" | |
| "gdelt-engine/internal/schema" | |
| "go.uber.org/zap" | |
| ) | |
| // Storage defines the interface for database operations. | |
| type Storage interface { | |
| IsTimestampCompleted(ctx context.Context, timestamp string) (bool, error) | |
| GetTimestampStatus(ctx context.Context, timestamp string) (*schema.CompletedTimestamp, error) | |
| MarkTimestampStarted(ctx context.Context, timestamp string) error | |
| MarkTimestampCompleted(ctx context.Context, ts *schema.CompletedTimestamp) error | |
| GetAllTimestamps(ctx context.Context) (completed []schema.CompletedTimestamp, processing []string, err error) | |
| MarkURLProcessed(ctx context.Context, url *schema.ProcessedURL) error | |
| GetProcessedURLs(ctx context.Context, timestamp string) ([]schema.ProcessedURL, error) | |
| BulkInsertArticles(ctx context.Context, articles []schema.Article) (int, error) | |
| } | |
| // ValidationResult represents the result of timestamp validation | |
| type ValidationResult struct { | |
| Timestamp string | |
| Valid bool | |
| Reason string | |
| Status schema.ProcessingStatus | |
| } | |
| // Task represents a single file processing task | |
| type Task struct { | |
| Timestamp string | |
| URL string | |
| FileType string | |
| } | |
| // Result represents the result of processing a single file | |
| type Result struct { | |
| Task Task | |
| RowCount int | |
| ArticleCount int | |
| Duration time.Duration | |
| Err error | |
| } | |
| // ProcessorOption is a functional option for Processor | |
| type ProcessorOption func(*Processor) | |
| func WithWorkers(n int) ProcessorOption { | |
| return func(p *Processor) { p.workers = n } | |
| } | |
| func WithTimeout(t time.Duration) ProcessorOption { | |
| return func(p *Processor) { p.timeout = t } | |
| } | |
| func WithBatchSize(n int) ProcessorOption { | |
| return func(p *Processor) { p.batchSize = n } | |
| } | |
| func WithMaxParallelTimestamps(n int) ProcessorOption { | |
| return func(p *Processor) { p.maxParallelTimestamps = n } | |
| } | |
| func WithFilterThemes(themes []string) ProcessorOption { | |
| return func(p *Processor) { p.filterThemes = themes } | |
| } | |
| func WithFilterLocations(locations []string) ProcessorOption { | |
| return func(p *Processor) { p.filterLocations = locations } | |
| } | |
| // Processor handles concurrent timestamp processing | |
| type Processor struct { | |
| workers int | |
| timeout time.Duration | |
| batchSize int | |
| maxParallelTimestamps int | |
| downloader *downloader.StreamingDownloader | |
| parser parser.StreamParser | |
| storage Storage | |
| logger *zap.Logger | |
| // Filters - if set, only articles matching these will be saved | |
| filterThemes []string // e.g., ["ECON_", "TAX_"] | |
| filterLocations []string // e.g., ["US", "IN"] | |
| // Semaphore for limiting parallel timestamps | |
| semaphore chan struct{} | |
| // Track processing state | |
| mu sync.RWMutex | |
| processing map[string]bool | |
| } | |
| // NewProcessor creates a new Processor with functional options | |
| func NewProcessor(storage Storage, logger *zap.Logger, opts ...ProcessorOption) *Processor { | |
| p := &Processor{ | |
| workers: constants.DefaultWorkers, | |
| timeout: constants.DefaultTimeout, | |
| batchSize: constants.DefaultBatchSize, | |
| maxParallelTimestamps: constants.MaxParallelTimestamps, | |
| storage: storage, | |
| logger: logger, | |
| processing: make(map[string]bool), | |
| } | |
| for _, opt := range opts { | |
| opt(p) | |
| } | |
| // Initialize semaphore for parallel timestamp limit | |
| p.semaphore = make(chan struct{}, p.maxParallelTimestamps) | |
| // Initialize downloader and parser | |
| p.downloader = downloader.NewStreamingDownloader( | |
| downloader.WithLogger(logger), | |
| ) | |
| p.parser = parser.NewCSVStreamParser( | |
| parser.WithParserLogger(logger), | |
| ) | |
| return p | |
| } | |
| // ValidateTimestampStructural performs structural validation (cheap, deterministic). | |
| // Checks: length, parsable, minute โ {00,15,30,45}, not in future. | |
| func ValidateTimestampStructural(ts string) *ValidationResult { | |
| result := &ValidationResult{Timestamp: ts} | |
| // Check length | |
| if len(ts) != constants.TimestampLength { | |
| result.Valid = false | |
| result.Reason = fmt.Sprintf("invalid length: expected %d, got %d", constants.TimestampLength, len(ts)) | |
| result.Status = schema.StatusFailed | |
| return result | |
| } | |
| // Parse as time | |
| t, err := time.Parse(constants.TimestampFormat, ts) | |
| if err != nil { | |
| result.Valid = false | |
| result.Reason = fmt.Sprintf("invalid format: %v", err) | |
| result.Status = schema.StatusFailed | |
| return result | |
| } | |
| // Check minute is valid GDELT interval (00, 15, 30, 45) | |
| m := t.Minute() | |
| if m%15 != 0 { | |
| result.Valid = false | |
| result.Reason = fmt.Sprintf("invalid minute %d: must be 00, 15, 30, or 45", m) | |
| result.Status = schema.StatusFailed | |
| return result | |
| } | |
| // Check not in future | |
| if t.After(time.Now().UTC()) { | |
| result.Valid = false | |
| result.Reason = "timestamp is in the future" | |
| result.Status = schema.StatusFailed | |
| return result | |
| } | |
| result.Valid = true | |
| return result | |
| } | |
| // isWithinGraceWindow checks if timestamp is within the grace window for recent data | |
| func isWithinGraceWindow(ts string) bool { | |
| t, err := time.Parse(constants.TimestampFormat, ts) | |
| if err != nil { | |
| return false | |
| } | |
| graceTime := time.Now().UTC().Add(-time.Duration(constants.GraceWindowMinutes) * time.Minute) | |
| return t.After(graceTime) | |
| } | |
| // QueueTimestamps validates and queues timestamps for processing. | |
| // Returns per-timestamp validation results. | |
| func (p *Processor) QueueTimestamps(ctx context.Context, timestamps []string) (queued []string, rejected []ValidationResult) { | |
| p.mu.Lock() | |
| defer p.mu.Unlock() | |
| for _, ts := range timestamps { | |
| // Layer 1: Structural validation | |
| validation := ValidateTimestampStructural(ts) | |
| if !validation.Valid { | |
| rejected = append(rejected, *validation) | |
| p.logger.Debug("Rejected invalid timestamp", | |
| zap.String("timestamp", ts), | |
| zap.String("reason", validation.Reason), | |
| ) | |
| continue | |
| } | |
| // Check if already processing | |
| if p.processing[ts] { | |
| rejected = append(rejected, ValidationResult{ | |
| Timestamp: ts, | |
| Valid: false, | |
| Reason: "already processing", | |
| Status: schema.StatusProcessing, | |
| }) | |
| continue | |
| } | |
| // Check if already completed | |
| completed, err := p.storage.IsTimestampCompleted(ctx, ts) | |
| if err != nil { | |
| p.logger.Error("Failed to check timestamp status", zap.String("timestamp", ts), zap.Error(err)) | |
| continue | |
| } | |
| if completed { | |
| rejected = append(rejected, ValidationResult{ | |
| Timestamp: ts, | |
| Valid: false, | |
| Reason: "already completed", | |
| Status: schema.StatusCompleted, | |
| }) | |
| continue | |
| } | |
| // Mark as processing | |
| p.processing[ts] = true | |
| queued = append(queued, ts) | |
| } | |
| // Start background processing for queued timestamps | |
| if len(queued) > 0 { | |
| go p.processTimestampsParallel(context.Background(), queued) | |
| } | |
| return queued, rejected | |
| } | |
| // processTimestampsParallel processes multiple timestamps in parallel with bounded concurrency. | |
| func (p *Processor) processTimestampsParallel(ctx context.Context, timestamps []string) { | |
| var wg sync.WaitGroup | |
| for _, ts := range timestamps { | |
| wg.Add(1) | |
| go func(timestamp string) { | |
| defer wg.Done() | |
| // Acquire semaphore (limit parallel timestamps) | |
| p.semaphore <- struct{}{} | |
| defer func() { <-p.semaphore }() | |
| p.processTimestamp(ctx, timestamp) | |
| }(ts) | |
| } | |
| wg.Wait() | |
| } | |
| // processTimestamp processes a single timestamp with existential validation | |
| func (p *Processor) processTimestamp(ctx context.Context, timestamp string) { | |
| start := time.Now() | |
| p.logger.Info("Processing timestamp", zap.String("timestamp", timestamp)) | |
| // Mark as started in database | |
| if err := p.storage.MarkTimestampStarted(ctx, timestamp); err != nil { | |
| p.logger.Error("Failed to mark timestamp started", zap.Error(err)) | |
| } | |
| // Create timeout context | |
| ctx, cancel := context.WithTimeout(ctx, p.timeout) | |
| defer cancel() | |
| // Generate tasks for all 3 files | |
| tasks := p.generateTasks(timestamp) | |
| // Layer 2: Existential validation via HEAD probing | |
| // Check if at least the export file exists | |
| exportURL := fmt.Sprintf(constants.ExportURLTemplate, timestamp) | |
| headResult, err := p.downloader.HeadCheck(ctx, exportURL) | |
| if headResult == downloader.HeadNotFound { | |
| // Check if within grace window | |
| if isWithinGraceWindow(timestamp) { | |
| p.logger.Info("Timestamp not yet published, within grace window", | |
| zap.String("timestamp", timestamp), | |
| ) | |
| p.markTimestampResult(ctx, timestamp, start, schema.StatusPending, | |
| "GDELT file not yet published (within grace window)", 0, 0, 0) | |
| } else { | |
| p.logger.Warn("Timestamp does not exist on GDELT", | |
| zap.String("timestamp", timestamp), | |
| ) | |
| p.markTimestampResult(ctx, timestamp, start, schema.StatusFailed, | |
| "GDELT file does not exist", 0, 0, 0) | |
| } | |
| p.removeFromProcessing(timestamp) | |
| return | |
| } | |
| if headResult == downloader.HeadError && err != nil { | |
| p.logger.Error("HEAD check failed", zap.String("timestamp", timestamp), zap.Error(err)) | |
| p.markTimestampResult(ctx, timestamp, start, schema.StatusFailed, | |
| fmt.Sprintf("HEAD check failed: %v", err), 0, 0, 0) | |
| p.removeFromProcessing(timestamp) | |
| return | |
| } | |
| // Process files in parallel with batching | |
| results := p.processFilesParallel(ctx, tasks) | |
| // Aggregate results | |
| var totalRows, totalArticles, filesProcessed int | |
| var lastError error | |
| for result := range results { | |
| if result.Err != nil { | |
| p.logger.Error("Task failed", | |
| zap.String("file_type", result.Task.FileType), | |
| zap.Error(result.Err), | |
| ) | |
| lastError = result.Err | |
| continue | |
| } | |
| filesProcessed++ | |
| totalRows += result.RowCount | |
| totalArticles += result.ArticleCount | |
| p.logger.Info("File processed", | |
| zap.String("file_type", result.Task.FileType), | |
| zap.Int("rows", result.RowCount), | |
| zap.Int("articles", result.ArticleCount), | |
| zap.Duration("duration", result.Duration), | |
| ) | |
| } | |
| // Mark completion | |
| status := schema.StatusCompleted | |
| errMsg := "" | |
| if lastError != nil { | |
| status = schema.StatusFailed | |
| errMsg = lastError.Error() | |
| } | |
| p.markTimestampResult(ctx, timestamp, start, status, errMsg, filesProcessed, totalRows, totalArticles) | |
| p.removeFromProcessing(timestamp) | |
| p.logger.Info("Timestamp completed", | |
| zap.String("timestamp", timestamp), | |
| zap.Int("files", filesProcessed), | |
| zap.Int("rows", totalRows), | |
| zap.Int("articles", totalArticles), | |
| zap.Duration("duration", time.Since(start)), | |
| ) | |
| } | |
| // processFilesParallel processes multiple files in parallel with batching | |
| func (p *Processor) processFilesParallel(ctx context.Context, tasks []Task) <-chan Result { | |
| results := make(chan Result, len(tasks)) | |
| var wg sync.WaitGroup | |
| // Process all files in parallel (limited by constants.MaxParallelFiles) | |
| fileSemaphore := make(chan struct{}, constants.MaxParallelFiles) | |
| for _, task := range tasks { | |
| wg.Add(1) | |
| go func(t Task) { | |
| defer wg.Done() | |
| // Acquire file semaphore | |
| fileSemaphore <- struct{}{} | |
| defer func() { <-fileSemaphore }() | |
| result := p.processTask(ctx, t) | |
| results <- result | |
| }(task) | |
| } | |
| go func() { | |
| wg.Wait() | |
| close(results) | |
| }() | |
| return results | |
| } | |
| // markTimestampResult saves the timestamp processing result to database | |
| func (p *Processor) markTimestampResult(ctx context.Context, timestamp string, start time.Time, | |
| status schema.ProcessingStatus, errMsg string, filesProcessed, totalRows, totalArticles int) { | |
| completedAt := time.Now() | |
| ts := &schema.CompletedTimestamp{ | |
| Timestamp: timestamp, | |
| Status: status, | |
| StartedAt: start, | |
| CompletedAt: &completedAt, | |
| FilesTotal: constants.FilesPerTimestamp, | |
| FilesProcessed: filesProcessed, | |
| ArticlesCount: totalArticles, | |
| TotalRows: totalRows, | |
| DurationMs: time.Since(start).Milliseconds(), | |
| Error: errMsg, | |
| } | |
| if err := p.storage.MarkTimestampCompleted(ctx, ts); err != nil { | |
| p.logger.Error("Failed to mark timestamp result", zap.Error(err)) | |
| } | |
| } | |
| // removeFromProcessing removes a timestamp from the processing map | |
| func (p *Processor) removeFromProcessing(timestamp string) { | |
| p.mu.Lock() | |
| delete(p.processing, timestamp) | |
| p.mu.Unlock() | |
| } | |
| // processTask handles a single file download and processing | |
| func (p *Processor) processTask(ctx context.Context, task Task) Result { | |
| start := time.Now() | |
| // Download stream | |
| reader, _, err := p.downloader.StreamDownload(ctx, task.URL) | |
| if err != nil { | |
| return Result{Task: task, Err: fmt.Errorf("download: %w", err)} | |
| } | |
| defer reader.Close() | |
| // Read ZIP content (required for zip.NewReader) | |
| zipData, err := io.ReadAll(reader) | |
| if err != nil { | |
| return Result{Task: task, Err: fmt.Errorf("read zip: %w", err)} | |
| } | |
| // Parse ZIP stream | |
| records := p.parser.ParseZipStream(ctx, zipData, task.FileType, task.Timestamp) | |
| // Collect and extract articles | |
| articles := make(map[string]*schema.Article) | |
| var rowCount int | |
| for result := range records { | |
| if result.Err != nil { | |
| if result.Err == context.Canceled || result.Err == context.DeadlineExceeded { | |
| return Result{Task: task, Err: result.Err} | |
| } | |
| continue | |
| } | |
| rowCount++ | |
| // Extract URL based on record type | |
| url := p.extractURL(result.Record, task.FileType) | |
| if url == "" { | |
| continue | |
| } | |
| // Apply filters when processing GKG files (only GKG has theme/location data) | |
| // When filters are set and we're processing GKG, check if record matches | |
| if p.hasFilters() && task.FileType == constants.FileTypeGKG { | |
| if !p.matchesFilters(result.Record) { | |
| continue // Skip articles that don't match filters | |
| } | |
| } else if p.hasFilters() && task.FileType != constants.FileTypeGKG { | |
| // When filters are set, only process GKG (has theme/location data) | |
| continue | |
| } | |
| // Create or update article | |
| normalized := normalizeURL(url) | |
| if normalized == "" { | |
| continue | |
| } | |
| id := hashURL(normalized) | |
| if _, ok := articles[id]; ok { | |
| // Article already seen, refresh expiration time | |
| articles[id].ExpiresAt = time.Now().Add(time.Duration(schema.ArticleTTLDays) * 24 * time.Hour) | |
| } else { | |
| article := &schema.Article{ | |
| ID: id, | |
| URL: url, | |
| ExpiresAt: time.Now().Add(time.Duration(schema.ArticleTTLDays) * 24 * time.Hour), | |
| } | |
| articles[id] = article | |
| } | |
| } | |
| // Save articles in batches | |
| articleSlice := make([]schema.Article, 0, len(articles)) | |
| for _, a := range articles { | |
| articleSlice = append(articleSlice, *a) | |
| } | |
| insertedCount := 0 | |
| if len(articleSlice) > 0 { | |
| inserted, err := p.storage.BulkInsertArticles(ctx, articleSlice) | |
| if err != nil { | |
| p.logger.Error("Failed to insert articles", zap.Error(err)) | |
| } | |
| insertedCount = inserted | |
| } | |
| // Mark URL as processed | |
| processedURL := &schema.ProcessedURL{ | |
| URL: task.URL, | |
| Timestamp: task.Timestamp, | |
| FileType: task.FileType, | |
| Status: schema.StatusCompleted, | |
| ProcessedAt: time.Now(), | |
| RowCount: rowCount, | |
| ArticlesExtracted: len(articles), | |
| DurationMs: time.Since(start).Milliseconds(), | |
| } | |
| if err := p.storage.MarkURLProcessed(ctx, processedURL); err != nil { | |
| p.logger.Error("Failed to mark URL processed", zap.Error(err)) | |
| } | |
| return Result{ | |
| Task: task, | |
| RowCount: rowCount, | |
| ArticleCount: insertedCount, | |
| Duration: time.Since(start), | |
| } | |
| } | |
| // generateTasks creates Task objects for all 3 GDELT files for a timestamp | |
| func (p *Processor) generateTasks(timestamp string) []Task { | |
| return []Task{ | |
| {Timestamp: timestamp, URL: fmt.Sprintf(constants.ExportURLTemplate, timestamp), FileType: constants.FileTypeExport}, | |
| {Timestamp: timestamp, URL: fmt.Sprintf(constants.MentionsURLTemplate, timestamp), FileType: constants.FileTypeMentions}, | |
| {Timestamp: timestamp, URL: fmt.Sprintf(constants.GKGURLTemplate, timestamp), FileType: constants.FileTypeGKG}, | |
| } | |
| } | |
| // extractURL extracts the article URL from a parsed record | |
| func (p *Processor) extractURL(record interface{}, fileType string) string { | |
| switch fileType { | |
| case constants.FileTypeExport: | |
| if event, ok := record.(*schema.Event); ok { | |
| return event.SourceURL | |
| } | |
| case constants.FileTypeMentions: | |
| if mention, ok := record.(*schema.Mention); ok { | |
| return mention.MentionIdentifier | |
| } | |
| case constants.FileTypeGKG: | |
| if gkg, ok := record.(*schema.GKG); ok { | |
| return gkg.DocumentIdentifier | |
| } | |
| } | |
| return "" | |
| } | |
| // hasFilters returns true if any filters are configured | |
| func (p *Processor) hasFilters() bool { | |
| return len(p.filterThemes) > 0 || len(p.filterLocations) > 0 | |
| } | |
| // matchesFilters checks if a GKG record matches the configured filters | |
| // Returns true if record matches any theme OR location filter (OR logic) | |
| func (p *Processor) matchesFilters(record interface{}) bool { | |
| gkg, ok := record.(*schema.GKG) | |
| if !ok { | |
| return false | |
| } | |
| // Check theme filters (prefix match) | |
| if len(p.filterThemes) > 0 { | |
| allThemes := append(gkg.Themes, gkg.V2Themes...) | |
| for _, filterTheme := range p.filterThemes { | |
| for _, theme := range allThemes { | |
| if strings.HasPrefix(strings.ToUpper(theme), strings.ToUpper(filterTheme)) { | |
| return true | |
| } | |
| } | |
| } | |
| } | |
| // Check location filters (prefix match on location string) | |
| if len(p.filterLocations) > 0 { | |
| allLocations := append(gkg.Locations, gkg.V2Locations...) | |
| for _, filterLoc := range p.filterLocations { | |
| for _, loc := range allLocations { | |
| if strings.HasPrefix(strings.ToUpper(loc), strings.ToUpper(filterLoc)) { | |
| return true | |
| } | |
| } | |
| } | |
| } | |
| // If themes filter is set but didn't match, and no location filter or didn't match | |
| return false | |
| } | |
| // GetTimestampStatus returns the processing status for a timestamp | |
| func (p *Processor) GetTimestampStatus(ctx context.Context, timestamp string) (interface{}, error) { | |
| p.mu.RLock() | |
| isProcessing := p.processing[timestamp] | |
| p.mu.RUnlock() | |
| if isProcessing { | |
| return map[string]interface{}{ | |
| "timestamp": timestamp, | |
| "status": "processing", | |
| }, nil | |
| } | |
| ts, err := p.storage.GetTimestampStatus(ctx, timestamp) | |
| if err != nil { | |
| return nil, err | |
| } | |
| if ts == nil { | |
| return map[string]interface{}{ | |
| "timestamp": timestamp, | |
| "status": "not_found", | |
| "message": "Timestamp not queued for processing", | |
| }, nil | |
| } | |
| return ts, nil | |
| } | |
| // GetAllTimestamps returns all completed and processing timestamps | |
| func (p *Processor) GetAllTimestamps(ctx context.Context) (interface{}, error) { | |
| completed, processing, err := p.storage.GetAllTimestamps(ctx) | |
| if err != nil { | |
| return nil, err | |
| } | |
| p.mu.RLock() | |
| for ts := range p.processing { | |
| if !containsString(processing, ts) { | |
| processing = append(processing, ts) | |
| } | |
| } | |
| p.mu.RUnlock() | |
| return map[string]interface{}{ | |
| "completed": completed, | |
| "processing": processing, | |
| "total_completed": len(completed), | |
| "total_processing": len(processing), | |
| }, nil | |
| } | |
| // Helper functions | |
| func normalizeURL(rawURL string) string { | |
| if rawURL == "" { | |
| return "" | |
| } | |
| u, err := url.Parse(rawURL) | |
| if err != nil { | |
| return "" | |
| } | |
| if u.Scheme == "" { | |
| u.Scheme = "http" | |
| } | |
| u.Host = strings.ToLower(u.Host) | |
| u.Host = strings.TrimPrefix(u.Host, "www.") | |
| q := u.Query() | |
| for _, param := range constants.TrackingParams { | |
| q.Del(param) | |
| } | |
| u.RawQuery = q.Encode() | |
| u.Fragment = "" | |
| u.Path = strings.TrimSuffix(u.Path, "/") | |
| return u.String() | |
| } | |
| func hashURL(normalizedURL string) string { | |
| hash := sha256.Sum256([]byte(normalizedURL)) | |
| return hex.EncodeToString(hash[:]) | |
| } | |
| func contains(slice []string, item string) bool { | |
| for _, s := range slice { | |
| if s == item { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| func containsString(slice []string, item string) bool { | |
| return contains(slice, item) | |
| } | |
| func mergeUnique(a, b []string) []string { | |
| seen := make(map[string]bool) | |
| for _, s := range a { | |
| seen[s] = true | |
| } | |
| for _, s := range b { | |
| if !seen[s] { | |
| a = append(a, s) | |
| seen[s] = true | |
| } | |
| } | |
| return a | |
| } | |
| // ValidateTimestamp is exported for use by API handlers | |
| func ValidateTimestamp(timestamp string) error { | |
| result := ValidateTimestampStructural(timestamp) | |
| if !result.Valid { | |
| return fmt.Errorf(result.Reason) | |
| } | |
| return nil | |
| } | |