Files
poimen-workflows/internal/batching/git_batch.go
T
Test d8fe3f5a3c feat(T2.5): implement git operation batching
- Add internal/batching package for git operation batching
- Implement GitBatcher with configurable batch size and age
- Queue git operations (commit, push, merge)
- Auto-flush on max batch size
- Manual flush on demand
- Time-based flush (max batch age)
- Batch status tracking (pending, executing, completed, failed)
- Network savings calculation
- Statistics tracking per batch and aggregated
- 24 batching tests, all passing

Features:
- Enqueue() for adding operations to queue
- Flush() for manual batch creation
- GetPendingBatch() for next pending batch
- MarkBatchExecuting/Completed/Failed() for status tracking
- GetStats() for batching statistics
- CalculateNetworkSavings() for round trip savings
- GetExecutedBatches() for completed batch history
- TimeSinceLastFlush() for age checking
- ShouldFlush() for time-based decisions

Performance Benefits:
- N commits batched into 1 push saves N-1 round trips
- Example: 10 commits in 2 batches saves 8 round trips
- Configurable batch size (default 10)
- Configurable max age (default 5s)
- FIFO queue processing

Network Savings Example:
- 10 operations in 2 batches of 5 each
- Network savings: 8 round trips (vs 10 individual operations)
- Verified in TestGetStats

Status Tracking:
- pending: queued and ready to execute
- executing: currently being executed
- completed: finished successfully
- failed: execution failed (kept for retry)

Test Coverage:
- 24 batching tests (enqueue, flush, status, stats)
- Auto-flush on max size verified
- Time-based flush behavior tested
- Network savings calculation verified
- Error handling and state management
- Concurrent safe operations (RWMutex)

Next: T2.6 (LLM request batching)
2026-08-23 17:22:23 -07:00

332 lines
7.2 KiB
Go

package batching
import (
"fmt"
"sync"
"time"
)
// GitOp represents a git operation to be batched
type GitOp struct {
OpType string // "commit", "push", "merge"
Branch string
Message string
Files []string
Timestamp time.Time
ID string
}
// GitBatch represents a batch of git operations
type GitBatch struct {
ID string
Operations []*GitOp
CreatedAt time.Time
ExecutedAt time.Time
Status string // "pending", "executing", "completed", "failed"
Error error
}
// GitBatcher batches git operations for efficient execution
type GitBatcher struct {
mu sync.RWMutex
queue []*GitOp
maxBatchSize int
maxBatchAge time.Duration
lastFlushTime time.Time
executedBatches []*GitBatch
pendingBatches []*GitBatch
stats *BatchStats
flushChan chan struct{}
stopChan chan struct{}
}
// BatchStats tracks batching statistics
type BatchStats struct {
TotalOps int
TotalBatches int
AvgOpsPerBatch float64
NetworkSavings int // Estimated network round trips saved
TotalExecuteTime time.Duration
}
// NewGitBatcher creates a new git batcher
func NewGitBatcher(maxBatchSize int, maxBatchAge time.Duration) *GitBatcher {
if maxBatchSize <= 0 {
maxBatchSize = 10
}
if maxBatchAge <= 0 {
maxBatchAge = 5 * time.Second
}
return &GitBatcher{
queue: make([]*GitOp, 0),
maxBatchSize: maxBatchSize,
maxBatchAge: maxBatchAge,
lastFlushTime: time.Now(),
executedBatches: make([]*GitBatch, 0),
pendingBatches: make([]*GitBatch, 0),
stats: &BatchStats{
TotalOps: 0,
TotalBatches: 0,
},
flushChan: make(chan struct{}, 1),
stopChan: make(chan struct{}),
}
}
// Enqueue adds a git operation to the queue
func (gb *GitBatcher) Enqueue(op *GitOp) {
if op == nil {
return
}
op.Timestamp = time.Now()
gb.mu.Lock()
defer gb.mu.Unlock()
gb.queue = append(gb.queue, op)
gb.stats.TotalOps++
// Auto-flush if batch is full
if len(gb.queue) >= gb.maxBatchSize {
gb.flushLocked()
}
}
// flushLocked creates a batch from queued operations (must be called with lock held)
func (gb *GitBatcher) flushLocked() {
if len(gb.queue) == 0 {
return
}
batch := &GitBatch{
ID: fmt.Sprintf("batch-%d", gb.stats.TotalBatches),
Operations: make([]*GitOp, len(gb.queue)),
CreatedAt: time.Now(),
Status: "pending",
}
copy(batch.Operations, gb.queue)
gb.pendingBatches = append(gb.pendingBatches, batch)
gb.queue = make([]*GitOp, 0)
gb.lastFlushTime = time.Now()
gb.stats.TotalBatches++
}
// Flush manually flushes the current batch
func (gb *GitBatcher) Flush() {
gb.mu.Lock()
defer gb.mu.Unlock()
gb.flushLocked()
}
// GetPendingBatch returns the next pending batch without removing it
func (gb *GitBatcher) GetPendingBatch() *GitBatch {
gb.mu.RLock()
defer gb.mu.RUnlock()
if len(gb.pendingBatches) == 0 {
return nil
}
return gb.pendingBatches[0]
}
// MarkBatchExecuting marks a batch as executing
func (gb *GitBatcher) MarkBatchExecuting(batchID string) {
gb.mu.Lock()
defer gb.mu.Unlock()
for _, batch := range gb.pendingBatches {
if batch.ID == batchID {
batch.Status = "executing"
break
}
}
}
// MarkBatchCompleted marks a batch as completed and removes from pending
func (gb *GitBatcher) MarkBatchCompleted(batchID string) {
gb.mu.Lock()
defer gb.mu.Unlock()
var idx int
var found *GitBatch
for i, batch := range gb.pendingBatches {
if batch.ID == batchID {
idx = i
found = batch
break
}
}
if found != nil {
found.Status = "completed"
found.ExecutedAt = time.Now()
// Move to executed batches
gb.executedBatches = append(gb.executedBatches, found)
// Remove from pending
gb.pendingBatches = append(gb.pendingBatches[:idx], gb.pendingBatches[idx+1:]...)
}
}
// MarkBatchFailed marks a batch as failed with an error
func (gb *GitBatcher) MarkBatchFailed(batchID string, err error) {
gb.mu.Lock()
defer gb.mu.Unlock()
var found *GitBatch
for _, batch := range gb.pendingBatches {
if batch.ID == batchID {
found = batch
break
}
}
if found != nil {
found.Status = "failed"
found.Error = err
found.ExecutedAt = time.Now()
// Keep in pending (for retry logic)
// Could also move to failed queue
}
}
// QueueSize returns the current queue size
func (gb *GitBatcher) QueueSize() int {
gb.mu.RLock()
defer gb.mu.RUnlock()
return len(gb.queue)
}
// PendingBatchCount returns the number of pending batches
func (gb *GitBatcher) PendingBatchCount() int {
gb.mu.RLock()
defer gb.mu.RUnlock()
return len(gb.pendingBatches)
}
// GetStats returns batching statistics
func (gb *GitBatcher) GetStats() *BatchStats {
gb.mu.RLock()
defer gb.mu.RUnlock()
stats := *gb.stats
if stats.TotalBatches > 0 {
stats.AvgOpsPerBatch = float64(stats.TotalOps) / float64(stats.TotalBatches)
// Estimated savings: each batch saves (ops-1) round trips
stats.NetworkSavings = stats.TotalOps - stats.TotalBatches
}
return &stats
}
// GetExecutedBatches returns all executed batches
func (gb *GitBatcher) GetExecutedBatches() []*GitBatch {
gb.mu.RLock()
defer gb.mu.RUnlock()
result := make([]*GitBatch, len(gb.executedBatches))
copy(result, gb.executedBatches)
return result
}
// GetBatchByID returns a specific batch by ID
func (gb *GitBatcher) GetBatchByID(batchID string) *GitBatch {
gb.mu.RLock()
defer gb.mu.RUnlock()
for _, batch := range gb.pendingBatches {
if batch.ID == batchID {
return batch
}
}
for _, batch := range gb.executedBatches {
if batch.ID == batchID {
return batch
}
}
return nil
}
// TimeSinceLastFlush returns time since last flush
func (gb *GitBatcher) TimeSinceLastFlush() time.Duration {
gb.mu.RLock()
defer gb.mu.RUnlock()
return time.Since(gb.lastFlushTime)
}
// ShouldFlush checks if batch should be flushed based on age
func (gb *GitBatcher) ShouldFlush() bool {
gb.mu.RLock()
defer gb.mu.RUnlock()
if len(gb.queue) == 0 {
return false
}
return time.Since(gb.lastFlushTime) >= gb.maxBatchAge
}
// Clear clears all pending operations and batches
func (gb *GitBatcher) Clear() {
gb.mu.Lock()
defer gb.mu.Unlock()
gb.queue = make([]*GitOp, 0)
gb.pendingBatches = make([]*GitBatch, 0)
gb.executedBatches = make([]*GitBatch, 0)
}
// GetQueuedOps returns a copy of queued operations
func (gb *GitBatcher) GetQueuedOps() []*GitOp {
gb.mu.RLock()
defer gb.mu.RUnlock()
ops := make([]*GitOp, len(gb.queue))
copy(ops, gb.queue)
return ops
}
// CalculateNetworkSavings calculates estimated network round trips saved
func (gb *GitBatcher) CalculateNetworkSavings() int {
gb.mu.RLock()
defer gb.mu.RUnlock()
totalSavings := 0
// Each batch of N operations saves N-1 round trips
for _, batch := range gb.executedBatches {
if len(batch.Operations) > 1 {
totalSavings += len(batch.Operations) - 1
}
}
return totalSavings
}
// GetBatchInfo returns human-readable batch information
func (batch *GitBatch) GetInfo() map[string]interface{} {
return map[string]interface{}{
"id": batch.ID,
"status": batch.Status,
"op_count": len(batch.Operations),
"created_at": batch.CreatedAt,
"executed_at": batch.ExecutedAt,
"duration": batch.ExecutedAt.Sub(batch.CreatedAt),
"error": batch.Error,
}
}