// Package mrqueue provides merge request queue storage and priority scoring. // // # MQ Priority Objective Function // // The merge queue uses a priority scoring function to determine processing order. // Higher scores mean higher priority (process first). // // ## Scoring Formula // // score = BaseScore // + ConvoyAgeWeight * hoursOld(convoy) // Prevent starvation // + PriorityWeight * (4 - priority) // P0 > P4 // - min(RetryPenalty * retryCount, MaxRetryPenalty) // Prevent thrashing // + MRAgeWeight * hoursOld(MR) // FIFO tiebreaker // // ## Default Weights // // BaseScore: 1000.0 (keeps all scores positive) // ConvoyAgeWeight: 10.0 (10 pts/hour = 240 pts/day) // PriorityWeight: 100.0 (P0=+400, P4=+0) // RetryPenalty: 50.0 (each retry loses 50 pts) // MRAgeWeight: 1.0 (1 pt/hour, minor FIFO factor) // MaxRetryPenalty: 300.0 (caps at 6 retries worth) // // ## Design Principles // // 1. Deterministic: same inputs always produce same score (uses explicit Now param) // // 2. Convoy Starvation Prevention: older convoys escalate in priority. A 48-hour // old P4 convoy will beat a fresh P0 standalone issue (+480 vs +400). // // 3. Priority Respect: within similar convoy ages, P0 issues beat P4 issues. // // 4. Thrashing Prevention: MRs that repeatedly fail with conflicts get // deprioritized, giving the repo state time to stabilize. // // 5. FIFO Fairness: within same convoy/priority/retry state, older MRs go first. // // ## Example Scores // // Fresh P0, no convoy: 1400 (1000 + 400) // Fresh P4, no convoy: 1000 (1000 + 0) // Fresh P2, 24h convoy: 1440 (1000 + 200 + 240) // Fresh P4, 48h convoy: 1480 (1000 + 0 + 480) // P2, 24h convoy, 3 retries: 1290 (1000 + 200 + 240 - 150) // P0, no convoy, 6+ retries (capped): 1100 (1000 + 400 - 300) // // ## Tuning // // All weights are configurable via ScoreConfig. The defaults are designed so: // - A 48-hour convoy beats any standalone priority (starvation prevention) // - Priority differences dominate within same convoy // - Retry penalty is significant but capped (eventual progress guaranteed) package mrqueue import ( "time" ) // ScoreConfig contains tunable weights for MR priority scoring. // All weights are designed so higher scores = higher priority (process first). type ScoreConfig struct { // BaseScore is the starting score before applying factors. // Default: 1000 (keeps all scores positive) BaseScore float64 // ConvoyAgeWeight is points added per hour of convoy age. // Older convoys get priority to prevent starvation. // Default: 10.0 (10 pts/hour = 240 pts/day) ConvoyAgeWeight float64 // PriorityWeight is multiplied by (4 - priority) so P0 gets most points. // P0 adds 4*weight, P1 adds 3*weight, ..., P4 adds 0*weight. // Default: 100.0 (P0 gets +400, P4 gets +0) PriorityWeight float64 // RetryPenalty is subtracted per retry attempt to prevent thrashing. // MRs that keep failing get deprioritized, giving repo state time to stabilize. // Default: 50.0 (each retry loses 50 pts) RetryPenalty float64 // MRAgeWeight is points added per hour since MR submission. // Minor factor for FIFO ordering within same priority/convoy. // Default: 1.0 (1 pt/hour) MRAgeWeight float64 // MaxRetryPenalty caps the total retry penalty to prevent permanent deprioritization. // Default: 300.0 (after 6 retries, penalty is capped) MaxRetryPenalty float64 } // DefaultScoreConfig returns sensible defaults for MR scoring. func DefaultScoreConfig() ScoreConfig { return ScoreConfig{ BaseScore: 1000.0, ConvoyAgeWeight: 10.0, PriorityWeight: 100.0, RetryPenalty: 50.0, MRAgeWeight: 1.0, MaxRetryPenalty: 300.0, } } // ScoreInput contains the data needed to score an MR. // This struct decouples scoring from the MR struct, allowing the // caller to provide convoy age from external lookups. type ScoreInput struct { // Priority is the issue priority (0=P0/critical, 4=P4/backlog). Priority int // MRCreatedAt is when the MR was submitted to the queue. MRCreatedAt time.Time // ConvoyCreatedAt is when the convoy was created. // Nil if MR is not part of a convoy (standalone work). ConvoyCreatedAt *time.Time // RetryCount is how many times this MR has been retried after conflicts. // 0 = first attempt. RetryCount int // Now is the current time (for deterministic testing). // If zero, time.Now() is used. Now time.Time } // ScoreMR calculates the priority score for a merge request. // Higher scores mean higher priority (process first). // // The scoring formula: // // score = BaseScore // + ConvoyAgeWeight * hoursOld(convoy) // Prevent convoy starvation // + PriorityWeight * (4 - priority) // P0=+400, P4=+0 // - min(RetryPenalty * retryCount, MaxRetryPenalty) // Prevent thrashing // + MRAgeWeight * hoursOld(MR) // FIFO tiebreaker // // Design principles: // - Deterministic: same inputs always produce same score // - Convoy starvation prevention: older convoys escalate in priority // - Priority respect: P0 bugs beat P4 backlog items // - Thrashing prevention: repeated failures get deprioritized // - FIFO fairness: within same convoy/priority, older MRs go first func ScoreMR(input ScoreInput, config ScoreConfig) float64 { now := input.Now if now.IsZero() { now = time.Now() } score := config.BaseScore // Convoy age factor: prevent starvation of old convoys if input.ConvoyCreatedAt != nil { convoyAge := now.Sub(*input.ConvoyCreatedAt) convoyHours := convoyAge.Hours() if convoyHours > 0 { score += config.ConvoyAgeWeight * convoyHours } } // Priority factor: P0 (0) gets +400, P4 (4) gets +0 priorityBonus := 4 - input.Priority if priorityBonus < 0 { priorityBonus = 0 // Clamp for invalid priorities > 4 } if priorityBonus > 4 { priorityBonus = 4 // Clamp for invalid priorities < 0 } score += config.PriorityWeight * float64(priorityBonus) // Retry penalty: prevent thrashing on repeatedly failing MRs retryPenalty := config.RetryPenalty * float64(input.RetryCount) if retryPenalty > config.MaxRetryPenalty { retryPenalty = config.MaxRetryPenalty } score -= retryPenalty // MR age factor: FIFO ordering as tiebreaker mrAge := now.Sub(input.MRCreatedAt) mrHours := mrAge.Hours() if mrHours > 0 { score += config.MRAgeWeight * mrHours } return score } // ScoreMRWithDefaults is a convenience wrapper using default config. func ScoreMRWithDefaults(input ScoreInput) float64 { return ScoreMR(input, DefaultScoreConfig()) } // Score calculates the priority score for this MR using default config. // Higher scores mean higher priority (process first). func (mr *MR) Score() float64 { return mr.ScoreAt(time.Now()) } // ScoreAt calculates the priority score at a specific time (for deterministic testing). func (mr *MR) ScoreAt(now time.Time) float64 { input := ScoreInput{ Priority: mr.Priority, MRCreatedAt: mr.CreatedAt, ConvoyCreatedAt: mr.ConvoyCreatedAt, RetryCount: mr.RetryCount, Now: now, } return ScoreMRWithDefaults(input) }