Files
bookhoard/internal/sync/queue.go
T
john-okeefe 936a48405b refactor(background): parameterize sync queue and worker pool constructors
Split each constructor into a default-args wrapper and a config-accepting
variant so the sync queue interval/batch size and the worker pool size/
queue cap can be sourced from the settings registry at startup. These
values are constructed once at boot, so they are tagged requires_restart
in the admin UI.

queue.go:
- NewSyncQueueProcessorWithConfig(db, interval, batchSize) takes the
  flush interval and batch size as parameters; NewSyncQueueProcessor
  becomes a thin wrapper with the historical 5s / 50 defaults.

worker.go:
- NewWorkerWithConfig(numWorkers, queueCap, connManager) takes the
  queue capacity as a parameter; NewWorker becomes a thin wrapper with
  the historical cap of 100.

No behavior change for existing callers; main.go will switch to the
config-accepting variants in a follow-up wiring commit.
2026-08-10 08:02:02 -04:00

710 lines
19 KiB
Go

package sync
import (
"bookhoard/internal/database"
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
const (
SyncTypeProgress = "progress"
SyncTypeNote = "note"
SyncTypeHighlight = "highlight"
SyncTypeBookmark = "bookmark"
)
const (
SyncStatusPending = "pending"
SyncStatusProcessing = "processing"
SyncStatusCompleted = "completed"
SyncStatusFailed = "failed"
)
const (
PriorityUserInitiated = 1
PriorityBookCompletion = 2
PriorityCriticalNote = 3
PriorityPageTurn = 5
PriorityCheckpoint = 7
PriorityBackgroundSync = 10
)
type SyncQueueProcessor struct {
db *database.Queries
progressSvc *ProgressService
annotationSvc *AnnotationService
progressChan chan *ProgressUpdate
interval time.Duration
batchSize int
}
type ProgressUpdate struct {
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
Percentage float64
Epubcfi *string
ContextText *string
Chapter *int
Character *int64
Page *int
TotalPages *int
Source string
SyncMode string
}
type SyncQueueItem struct {
ID pgtype.UUID
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
SyncType string
SyncData []byte
Priority int32
Attempts int32
MaxAttempts int32
Status string
ErrorMessage *string
CreatedAt time.Time
ProcessedAt *time.Time
}
func NewSyncQueueProcessor(db *database.Queries) *SyncQueueProcessor {
return NewSyncQueueProcessorWithConfig(db, 5*time.Second, 50)
}
// NewSyncQueueProcessorWithConfig constructs a processor with the given flush
// interval and batch size. Used at startup to source values from the settings
// registry.
func NewSyncQueueProcessorWithConfig(db *database.Queries, interval time.Duration, batchSize int) *SyncQueueProcessor {
return &SyncQueueProcessor{
db: db,
progressChan: make(chan *ProgressUpdate, 100),
interval: interval,
batchSize: batchSize,
}
}
func (p *SyncQueueProcessor) SetProgressService(svc *ProgressService) {
p.progressSvc = svc
}
func (p *SyncQueueProcessor) SetAnnotationService(svc *AnnotationService) {
p.annotationSvc = svc
}
func (p *SyncQueueProcessor) Start(ctx context.Context) {
log.Printf("Starting sync queue processor (interval: %v, batch: %d)", p.interval, p.batchSize)
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("Sync queue processor stopped")
return
case update := <-p.progressChan:
p.enqueueProgressUpdate(ctx, update)
case <-ticker.C:
p.processQueue(ctx)
}
}
}
func (p *SyncQueueProcessor) EnqueueProgress(update *ProgressUpdate) error {
select {
case p.progressChan <- update:
return nil
default:
return fmt.Errorf("progress channel is full")
}
}
type HighlightUpdate struct {
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
SelectionText string
StartPosition string
EndPosition string
Color string
NoteText string
EpubcfiStart string
EpubcfiEnd string
PercentageStart float64
PercentageEnd float64
Source string
DeviceSyncData map[string]interface{}
}
type NoteUpdate struct {
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
Content string
Position string
Source string
DeviceSyncData map[string]interface{}
}
type BookmarkUpdate struct {
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
Title string
Position string
Notes string
Source string
DeviceSyncData map[string]interface{}
}
func (p *SyncQueueProcessor) EnqueueHighlight(ctx context.Context, update *HighlightUpdate) error {
syncData := map[string]interface{}{
"selection_text": update.SelectionText,
"start_position": update.StartPosition,
"end_position": update.EndPosition,
"color": update.Color,
"note_text": update.NoteText,
"source": update.Source,
"epubcfi_start": update.EpubcfiStart,
"epubcfi_end": update.EpubcfiEnd,
"percentage_start": update.PercentageStart,
"percentage_end": update.PercentageEnd,
"device_sync_data": update.DeviceSyncData,
}
return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeHighlight, syncData)
}
func (p *SyncQueueProcessor) EnqueueNote(ctx context.Context, update *NoteUpdate) error {
syncData := map[string]interface{}{
"content": update.Content,
"position": update.Position,
"source": update.Source,
"device_sync_data": update.DeviceSyncData,
}
return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeNote, syncData)
}
func (p *SyncQueueProcessor) EnqueueBookmark(ctx context.Context, update *BookmarkUpdate) error {
syncData := map[string]interface{}{
"title": update.Title,
"position": update.Position,
"notes": update.Notes,
"source": update.Source,
"device_sync_data": update.DeviceSyncData,
}
return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeBookmark, syncData)
}
func (p *SyncQueueProcessor) enqueueAnnotation(ctx context.Context, deviceID, mediaItemID pgtype.UUID, syncType string, syncData map[string]interface{}) error {
syncDataJSON, err := json.Marshal(syncData)
if err != nil {
return fmt.Errorf("marshal sync data: %w", err)
}
_, err = p.db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{
DeviceID: deviceID,
MediaItemID: mediaItemID,
SyncType: syncType,
SyncData: syncDataJSON,
Priority: pgtype.Int4{Int32: int32(PriorityCriticalNote), Valid: true},
MaxAttempts: pgtype.Int4{Int32: 3, Valid: true},
Status: pgtype.Text{String: SyncStatusPending, Valid: true},
})
return err
}
func (p *SyncQueueProcessor) enqueueProgressUpdate(ctx context.Context, update *ProgressUpdate) {
syncData := map[string]interface{}{
"percentage": update.Percentage,
"source": update.Source,
"timestamp": time.Now().Format(time.RFC3339),
}
if update.Epubcfi != nil {
syncData["epubcfi"] = *update.Epubcfi
}
if update.ContextText != nil {
syncData["context_text"] = *update.ContextText
}
if update.Chapter != nil {
syncData["chapter"] = *update.Chapter
}
if update.Character != nil {
syncData["character"] = *update.Character
}
if update.Page != nil {
syncData["page"] = *update.Page
}
if update.TotalPages != nil {
syncData["total_pages"] = *update.TotalPages
}
syncDataJSON, err := json.Marshal(syncData)
if err != nil {
log.Printf("Failed to marshal sync data: %v", err)
return
}
priority := PriorityPageTurn
if update.SyncMode == "checkpoint" {
priority = PriorityCheckpoint
} else if update.Percentage >= 0.99 {
priority = PriorityBookCompletion
}
maxAttempts := int32(3)
if update.SyncMode == "immediate" {
maxAttempts = 5
}
_, err = p.db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{
DeviceID: update.DeviceID,
MediaItemID: update.MediaItemID,
SyncType: SyncTypeProgress,
SyncData: syncDataJSON,
Priority: pgtype.Int4{Int32: int32(priority), Valid: true},
MaxAttempts: pgtype.Int4{Int32: maxAttempts, Valid: true},
Status: pgtype.Text{String: SyncStatusPending, Valid: true},
})
if err != nil {
log.Printf("Failed to create sync queue item: %v", err)
}
}
func (p *SyncQueueProcessor) processQueue(ctx context.Context) {
items := p.getPendingItems(ctx)
if len(items) == 0 {
p.checkStuckItems(ctx)
return
}
log.Printf("Processing %d sync queue items", len(items))
for _, item := range items {
select {
case <-ctx.Done():
return
default:
p.processItem(ctx, item)
}
}
}
func (p *SyncQueueProcessor) getPendingItems(ctx context.Context) []SyncQueueItem {
var allItems []SyncQueueItem
var offset int32 = 0
batchSize := int32(p.batchSize)
for {
dbItems, err := p.db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{
DeviceID: pgtype.UUID{Valid: false},
Limit: batchSize,
})
if err != nil {
log.Printf("Error listing pending items: %v", err)
break
}
if len(dbItems) == 0 {
break
}
for _, item := range dbItems {
if item.Status.String == SyncStatusPending {
allItems = append(allItems, dbItemToSyncQueueItem(item))
}
}
if int32(len(dbItems)) < batchSize {
break
}
offset += batchSize
}
return allItems
}
func (p *SyncQueueProcessor) checkStuckItems(ctx context.Context) {
items, err := p.db.GetStuckSyncQueueItems(ctx)
if err != nil {
return
}
for _, item := range items {
if item.Attempts.Int32 < item.MaxAttempts.Int32 {
log.Printf("Resetting stuck queue item %s", item.ID.Bytes)
_, err := p.db.UpdateSyncQueueItemStatus(ctx, database.UpdateSyncQueueItemStatusParams{
ID: item.ID,
Status: pgtype.Text{String: SyncStatusPending, Valid: true},
ErrorMessage: pgtype.Text{String: "Item was stuck in processing state", Valid: true},
})
if err != nil {
log.Printf("Failed to reset stuck item: %v", err)
}
}
}
}
func (p *SyncQueueProcessor) processItem(ctx context.Context, item SyncQueueItem) {
_, err := p.db.UpdateSyncQueueItemStatus(ctx, database.UpdateSyncQueueItemStatusParams{
ID: item.ID,
Status: pgtype.Text{String: SyncStatusProcessing, Valid: true},
ErrorMessage: pgtype.Text{},
})
if err != nil {
log.Printf("Failed to update item status to processing: %v", err)
return
}
var syncData map[string]interface{}
err = json.Unmarshal(item.SyncData, &syncData)
if err != nil {
log.Printf("Failed to unmarshal sync data: %v", err)
p.markItemFailed(ctx, item, fmt.Sprintf("Invalid sync data: %v", err))
return
}
err = p.executeSync(ctx, item, syncData)
if err != nil {
log.Printf("Sync failed for item %s: %v", item.ID.Bytes, err)
if item.Attempts >= item.MaxAttempts {
p.markItemFailed(ctx, item, fmt.Sprintf("Max attempts reached: %v", err))
} else {
nextRetry := p.calculateNextRetry(item.Attempts)
log.Printf("Scheduling retry for item %s at %v (attempt %d/%d)",
item.ID.Bytes, nextRetry, item.Attempts+1, item.MaxAttempts)
p.markItemPending(ctx, item, fmt.Sprintf("Will retry: %v", err))
}
return
}
_, err = p.db.UpdateSyncQueueItemStatus(ctx, database.UpdateSyncQueueItemStatusParams{
ID: item.ID,
Status: pgtype.Text{String: SyncStatusCompleted, Valid: true},
ErrorMessage: pgtype.Text{},
})
if err != nil {
log.Printf("Failed to mark item as completed: %v", err)
}
log.Printf("Successfully processed sync queue item %s", item.ID.Bytes)
}
func (p *SyncQueueProcessor) executeSync(ctx context.Context, item SyncQueueItem, syncData map[string]interface{}) error {
device, err := p.db.GetDevice(ctx, item.DeviceID)
if err != nil {
return fmt.Errorf("device not found: %w", err)
}
switch item.SyncType {
case SyncTypeProgress:
return p.syncProgress(ctx, device.UserID, item.MediaItemID, syncData)
case SyncTypeNote:
return p.syncNote(ctx, device.UserID, item.MediaItemID, syncData)
case SyncTypeHighlight:
return p.syncHighlight(ctx, device.UserID, item.MediaItemID, syncData)
case SyncTypeBookmark:
return p.syncBookmark(ctx, device.UserID, item.MediaItemID, syncData)
default:
return fmt.Errorf("unsupported sync type: %s", item.SyncType)
}
}
func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
percentage, ok := syncData["percentage"].(float64)
if !ok {
return fmt.Errorf("missing percentage in sync data")
}
source := "queue"
if v, ok := syncData["source"].(string); ok {
source = v
}
if p.progressSvc != nil {
req := SaveProgressRequest{
MediaItemID: mediaItemID,
UserID: userID,
Source: source,
Percentage: &percentage,
Broadcast: false,
}
if v, ok := syncData["epubcfi"].(string); ok {
req.Epubcfi = &v
}
if v, ok := syncData["context_text"].(string); ok {
req.ContextText = &v
}
if v, ok := syncData["chapter"].(float64); ok {
ch := int(v)
req.Chapter = &ch
}
if v, ok := syncData["character"].(float64); ok {
co := int64(v)
req.CharacterOffset = &co
}
if v, ok := syncData["page"].(float64); ok {
pg := int(v)
req.CurrentPage = &pg
}
if v, ok := syncData["total_pages"].(float64); ok {
tp := int(v)
req.TotalPages = &tp
}
_, err := p.progressSvc.SaveProgress(ctx, req)
return err
}
var epubcfi pgtype.Text
if v, ok := syncData["epubcfi"].(string); ok {
epubcfi = pgtype.Text{String: v, Valid: true}
}
var chapter pgtype.Int4
if v, ok := syncData["chapter"].(float64); ok {
chapter = pgtype.Int4{Int32: int32(v), Valid: true}
}
var characterOffset pgtype.Int8
if v, ok := syncData["character"].(float64); ok {
characterOffset = pgtype.Int8{Int64: int64(v), Valid: true}
}
var currentPage pgtype.Int4
if v, ok := syncData["page"].(float64); ok {
currentPage = pgtype.Int4{Int32: int32(v), Valid: true}
}
var totalPages pgtype.Int4
if v, ok := syncData["total_pages"].(float64); ok {
totalPages = pgtype.Int4{Int32: int32(v), Valid: true}
}
_, err := p.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
CharacterOffset: characterOffset,
CurrentPage: currentPage,
TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: source, Valid: true},
LastSyncSource: pgtype.Text{String: source, Valid: true},
ViewportX: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{},
PanelNumber: pgtype.Int4{},
ReadingMode: pgtype.Text{},
ZoomLevel: pgtype.Float8{},
})
return err
}
func (p *SyncQueueProcessor) syncNote(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
if p.annotationSvc == nil {
return fmt.Errorf("annotation service not available")
}
req := SaveNoteRequest{
MediaItemID: mediaItemID,
UserID: userID,
}
if v, ok := syncData["content"].(string); ok {
req.Content = v
}
if v, ok := syncData["position"].(string); ok {
req.Position = v
}
if v, ok := syncData["source"].(string); ok {
req.Source = v
}
if v, ok := syncData["epubcfi_location"].(string); ok {
req.EpubcfiLocation = v
}
if v, ok := syncData["percentage_location"].(float64); ok {
req.PercentageLocation = v
}
if v, ok := syncData["chapter_reference"].(float64); ok {
req.ChapterReference = int32(v)
}
if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok {
req.DeviceSyncData, _ = json.Marshal(v)
}
_, err := p.annotationSvc.SaveNote(ctx, req)
return err
}
func (p *SyncQueueProcessor) syncHighlight(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
if p.annotationSvc == nil {
return fmt.Errorf("annotation service not available")
}
req := SaveHighlightRequest{
MediaItemID: mediaItemID,
UserID: userID,
}
if v, ok := syncData["selection_text"].(string); ok {
req.SelectionText = v
}
if v, ok := syncData["start_position"].(string); ok {
req.StartPosition = v
}
if v, ok := syncData["end_position"].(string); ok {
req.EndPosition = v
}
if v, ok := syncData["color"].(string); ok {
req.Color = v
}
if v, ok := syncData["note_text"].(string); ok {
req.NoteText = v
}
if v, ok := syncData["source"].(string); ok {
req.Source = v
}
if v, ok := syncData["epubcfi_start"].(string); ok {
req.EpubcfiStart = v
}
if v, ok := syncData["epubcfi_end"].(string); ok {
req.EpubcfiEnd = v
}
if v, ok := syncData["percentage_start"].(float64); ok {
req.PercentageStart = v
}
if v, ok := syncData["percentage_end"].(float64); ok {
req.PercentageEnd = v
}
if v, ok := syncData["chapter_reference"].(float64); ok {
req.ChapterReference = int32(v)
}
if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok {
req.DeviceSyncData, _ = json.Marshal(v)
}
_, err := p.annotationSvc.SaveHighlight(ctx, req)
return err
}
func (p *SyncQueueProcessor) syncBookmark(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
if p.annotationSvc == nil {
return fmt.Errorf("annotation service not available")
}
req := SaveBookmarkRequest{
MediaItemID: mediaItemID,
UserID: userID,
}
if v, ok := syncData["title"].(string); ok {
req.Title = v
}
if v, ok := syncData["position"].(string); ok {
req.Position = v
}
if v, ok := syncData["notes"].(string); ok {
req.Notes = v
}
if v, ok := syncData["source"].(string); ok {
req.Source = v
}
if v, ok := syncData["cfi_position"].(string); ok {
req.CFIPosition = v
}
if v, ok := syncData["epubcfi_location"].(string); ok {
req.EpubcfiLocation = v
}
if v, ok := syncData["percentage_loc"].(float64); ok {
req.PercentageLoc = v
}
if v, ok := syncData["page_number"].(float64); ok {
req.PageNumber = int32(v)
}
if v, ok := syncData["chapter_number"].(float64); ok {
req.ChapterNumber = int32(v)
}
if v, ok := syncData["chapter_reference"].(float64); ok {
req.ChapterReference = int32(v)
}
if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok {
req.DeviceSyncData, _ = json.Marshal(v)
}
_, err := p.annotationSvc.SaveBookmark(ctx, req)
return err
}
func (p *SyncQueueProcessor) markItemFailed(ctx context.Context, item SyncQueueItem, errMsg string) {
_, err := p.db.UpdateSyncQueueItemStatus(ctx, database.UpdateSyncQueueItemStatusParams{
ID: item.ID,
Status: pgtype.Text{String: SyncStatusFailed, Valid: true},
ErrorMessage: pgtype.Text{String: errMsg, Valid: true},
})
if err != nil {
log.Printf("Failed to mark item as failed: %v", err)
}
}
func (p *SyncQueueProcessor) markItemPending(ctx context.Context, item SyncQueueItem, errMsg string) {
_, err := p.db.UpdateSyncQueueItemStatus(ctx, database.UpdateSyncQueueItemStatusParams{
ID: item.ID,
Status: pgtype.Text{String: SyncStatusPending, Valid: true},
ErrorMessage: pgtype.Text{String: errMsg, Valid: true},
})
if err != nil {
log.Printf("Failed to mark item as pending: %v", err)
}
}
func (p *SyncQueueProcessor) calculateNextRetry(attempts int32) time.Time {
switch attempts {
case 0:
return time.Now()
case 1:
return time.Now().Add(1 * time.Minute)
case 2:
return time.Now().Add(5 * time.Minute)
case 3:
return time.Now().Add(15 * time.Minute)
case 4:
return time.Now().Add(1 * time.Hour)
default:
return time.Now().Add(24 * time.Hour)
}
}
func (p *SyncQueueProcessor) GetQueueStats(ctx context.Context, deviceID pgtype.UUID) (database.GetSyncQueueStatsRow, error) {
return p.db.GetSyncQueueStats(ctx, deviceID)
}
func dbItemToSyncQueueItem(item database.SyncQueue) SyncQueueItem {
return SyncQueueItem{
ID: item.ID,
DeviceID: item.DeviceID,
MediaItemID: item.MediaItemID,
SyncType: item.SyncType,
Priority: item.Priority.Int32,
Attempts: item.Attempts.Int32,
MaxAttempts: item.MaxAttempts.Int32,
Status: item.Status.String,
ErrorMessage: &item.ErrorMessage.String,
CreatedAt: item.CreatedAt.Time,
ProcessedAt: &item.ProcessedAt.Time,
}
}