- 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)
332 lines
7.2 KiB
Go
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,
|
|
}
|
|
}
|