關於開源貢獻為何會積壓超過一年,一本排隊理論的書告訴我們什麼。

我嘗試將一個功能加入 Jellyfin web 已超過一年。我提交了三個 PR,甚至獲得了兩個批准,但沒有一個被合併。這個功能相對獨立且小巧。當你在播放器中調整字幕偏移時,它會添加一個時間軸,精確顯示哪個字幕將在何時出現,這樣你就能清楚看到你在做什麼,而不是憑猜測。

一位審查者在第一天就給予了詳細的反饋。我按照他們的要求將工作分解成更小的部分,當天就做出了回應,並將程式碼風格調整為符合專案要求。重構的 PR 獲得了兩個批准,但就這樣擱置在那裡。最小的 PR,只增加了 49 行程式碼,刪除了 0 行,從未獲得任何人工審查。

我在 Jellyfin 社群中提到了這件事,有人開玩笑說,對 Jellyfin 的 PR 來說,一年還不算長。主要維護者表示,積壓的狀況「對每個人來說都很糟糕」,而且他每秒不審查,情況就會變得更糟。我詢問是否可以透過設定 PR 大小限制、提案要求、不同的流程來幫助改善。答案大多是「只有一個維護者」。

我認為這其中還有更多原因。

這並不罕見,大多數受歡迎的開源專案都面臨同樣的問題。

CPython 有超過 2,200 個未處理的 PR。在 2022 年的 Python 語言峰會上,一位核心開發者專門就積壓問題進行了演講,並指出了雞生蛋、蛋生雞的問題:一個模組沒有活躍的審查者,就沒有審查,也就沒有新審查者得到培訓。

Evan You,Vue.js 的創始人,曾公開談論過隨著專案的成長,問題的數量變得難以應對。2024 年的一項 Tidelift 調查發現,60% 的開源維護者已經辭職或考慮辭職。一份 Ford Foundation 的報告發現,絕大多數開源專案是由一兩個人維護的。

Jellyfin 只是我遇到這個問題的地方。我認為解決方案不是「尋找更多維護者」,而是改變工作在你現有的唯一維護者身上的流動方式。

我的三個 PR 總共耗時 368 天。綠色部分代表有人實際審查、編寫程式碼或回應的日子。其餘時間都在等待。

那些綠色時段內的工作是好的,有詳細的程式碼審查、架構上的來回討論、應要求拆分 PR、解決每一個要求,但由於 PR 在獲得兩個批准後仍然沒有被合併,所以這些工作都沒有任何進展。

Jellyfin web 大約有 200 個未處理的 PR,每月大約合併 20 到 35 個實際的程式碼 PR(排除自動化的依賴更新後),這對於一個志工專案來說已經很多了。大約有 77 個未處理的功能 PR,但功能 PR 只佔實際合併內容的約 21%,錯誤修復 PR 可以通過,但功能 PR 就這樣擱置著。

Donald Reinertsen 的《The Principles of Product Development Flow》將排隊理論應用於產品開發,其核心思想是:當一個資源的利用率接近 100% 時,佇列會呈指數級增長,而不是線性增長。M/M/1 佇列公式是:等待時間 = 利用率 / (1 - 利用率)。在 50% 的利用率下,等待時間是 1 倍。在 80% 時,是 4 倍。在 90% 時,是 9 倍。在 95% 時,是 19 倍。

維護者說,每秒不審查,情況就會變得更糟,這正是陷阱所在,因為覺得閒置時間是浪費的感覺會讓你趨向 100% 的利用率,而這正是曲線變得垂直的地方,你無法透過努力工作來克服指數級的增長。

Little's Law 給出了數字:週期時間 = 在製品 / 吞吐量。有 200 個 PR 在佇列中,每月約合併 30 個,平均週期時間約為 6.7 個月。這不是暫時的積壓,而是系統在此負載下的正常表現。

大型 PR 需要更長的審查時間,因此它們會堆積起來,貢獻者看到堆積的 PR,覺得反正都在等待,不如把更多變更打包進去,這使得 PR 更大,審查更慢,堆積的 PR 就更多。Reinertsen 將此稱為「批次大小死亡螺旋」,我認為這個名字很貼切。

在 Jellyfin web 的 200 個未處理 PR 中,有 30 個有合併衝突,31 個被標記為過時。這些 PR 現在比剛打開時更難合併,而且隨著時間的推移只會變得更糟,它不會自行修復。

審查的時間越長,其價值就越低。3 天內,貢獻者仍然有上下文記憶,可以快速修改;3 個月後,他們已經轉移了注意力,程式碼庫也已發生變化,重新基礎化(rebasing)本身就成了一個大工程。

2021 年的一項研究分析了 GitHub 上熱門專案的超過 265,000 個 PR,發現貢獻者放棄 PR 最常見的原因是他們遇到的障礙和維護者在審查過程中施加的阻礙,而不是直接拒絕。

限制理論(Theory of Constraints)指出:找出瓶頸,從中榨取最大價值,讓其他一切都為其服務。我認為以下所有建議都可以在沒有新維護者的情況下完成,它們是關於保護你已有的時間。

對功能 PR 設定一個嚴格的變更行數限制,例如 300 行,或者至少自動標記大小,引導貢獻者提交更小的、可以獨立合併的 PR。

小型 PR 審查速度更快,風險更低,反饋也更快。專案最大的擔憂是破壞播放功能而沒有測試覆蓋,因此較小的變更應該更容易被接受。貢獻指南已經說明 PR「應該代表單一的焦點」。一個標記過大 PR 的機器人將這個建議變成了真正的限制。Kubernetes 使用自動大小標籤來實現這一點,以便審查者可以選擇適合他們時間的 PR。

維護者花在 CI 失敗、缺少描述或合併衝突的 PR 上的每一分鐘都是浪費的,而且與過程中任何其他時間點的損失不同,在瓶頸處損失的時間就是整個系統的時間損失。

Jellyfin 已經有一個帶有檢查清單的 PR 模板,並且 CI 會運行程式碼風格檢查。LLM 政策過濾低質量的 AI 程式碼。下一步:當 CI 標記為紅色時,自動將 PR 標記為「未準備好」,也許在設定時間後自動關閉有合併衝突的 PR。Homebrew 大量依賴自動化來維持維護者的負載可持續性。

一個人一次真正能審查多少個 PR?也許 5 個,也許 10 個,但肯定不是 200 個。對活躍審查中的 PR 設定一個硬性上限,要求完成或拒絕後才能開始新的 PR,這會有幫助,因為在半審查的 PR 之間切換意味著需要重新閱讀上下文和重新檢查變更內容,而面對 200 個未處理的 PR,人們傾向於快速瀏覽很多,但深入處理少數幾個並強制完成會效果更好,而且嘗試起來也零成本。

並非所有 PR 的價值都相同。安全修復的延遲成本很高。外觀上的改變成本幾乎為零。但這裡還有一個不明顯的成本。如果阻力最小的路徑是合併重構和錯誤修復,因為它們更小、更安全,那麼功能就永遠無法落地。應用程式會停滯不前,貢獻者會停止出現,因為他們看到一個只做維護的專案,這本身就是一種死亡螺旋。

系統應該讓功能更容易被納入,而不僅僅是理論上的可能性。這意味著功能需要一個清晰的路徑:提案、大小限制、審查優先級。Reinertsen 將這個總體原則稱為 WSJF(加權最短工作優先),按延遲成本除以審查所需工作量,優先處理得分最高的。但功能上的延遲成本很容易被低估,因為它分散在每個沒有得到他們想要的東西的用戶身上,以及每個不再回來的貢獻者身上。

設定一個固定的每週 PR 分類時間,用於處理新 PR、完成正在審查的 PR,以及禮貌地關閉過時的 PR。重點不是時間表本身,而是讓事情變得可預測。目前沒有辦法知道何時會收到反饋,可能是 3 天或 300 天,沒有這種可預測性,貢獻者就會想「我在等待的時候,再加點別的東西吧」,這就是 PR 從 50 行變成 500 行的原因。每週的節奏將「7 天內首次回應」變成貢獻者可以真正依賴的東西。

即使是部分回應,例如「看過了,概念不錯,完整審查將在第三週進行」,成本幾乎為零,卻能讓貢獻者不必再猜測他們的工作是否石沉大海。

讓少數重複貢獻者獲得審查者身份,他們可以批准但不能合併,維護者可以快速檢查已批准的 PR,這比從頭開始進行完整審查要快得多。PR 檢查清單已經要求貢獻者在自己的 PR 獲得關注之前先審查別人的 PR,這是一個正確的思路,將其結構化並賦予真正的審查者角色將會成倍增加審查能力。

文件說明功能應該從提案開始,但這並沒有被強制執行,如果將其設為硬性門檻,那麼沒有獲得批准提案的功能 PR 將不會進入審查佇列。

這是我個人感受最深的一點。我多次詢問要如何才能讓我的功能被合併,什麼是正確的方法,應該按什麼順序進行,但我從未得到答覆。提案流程本可以在我編寫任何程式碼之前就促成這種對話,我們可以就範圍達成一致,我會知道專案真正需要什麼,而不是憑猜測。

Rust 的 RFC 流程可能是最清晰的例子。它創建於 2014 年,因為當時功能不斷被合併,但沒有事先達成共識,這使得專案能夠在不按比例擴大核心團隊的情況下擴大貢獻。

我之間的對話卡在了「只有一個維護者,所以 PR 要花費很長時間」這個點上,我根本不相信這個說法。瓶頸不在於只有一個人,而是 200 個不同質量和大小的 PR 都通過一個沒有流量控制的點,而維護者的時間(確實非常寶貴)卻花在沒有描述的 PR、1000 行的功能 PR、已經擱置數月的帶有合併衝突的 PR 上,所有這些需求都產生了零產出。保護這些時間與要求更多時間是不同的事情。

我每天都使用 Jellyfin,並且想為它做出貢獻。也許其中一些想法值得嘗試,或者也許有人能告訴我如何讓字幕時間軸被合併,這樣我就永遠不會再談論排隊理論了,我其實兩種情況都接受。