總結

可透過 HTTP 選擇性下載 crates-io 索引,類於 Ruby 的 Bundler 的作法。傳輸方式從預先(ahead-of-time)Git clone 改變為 HTTP 按需下載。既有的索引結構和內容維持不變。重要的是,此提案可與靜態檔案協同運作,而不需客製化的伺服器端 API。

動機

完整的 crate 索引相對龐大且下載慢。它還會隨著 crates.io 增長而增長,讓情況每況愈下。需要下載完整索引這件事拖累了首次使用 Cargo 的速度。尤其是在無狀態的 CI 環境下更是緩慢又浪費且只使用完整索引的非常小一部分,剩下全都給拋棄。快取索引在 CI 環境可謂困難重重(.cargo 目錄很巨)又經常低效(如:在 Travis Ci 上傳下載大型快取幾乎和下載新鮮索引一樣慢)。

目前索引的儲存資料格式和 git 協定搭配起來並不是很舒服。未壓縮的索引內容 tarball(截止 eb037b4863)佔 176MiB,gzip -1 需要 16MiB,而 xz -6 壓縮則要 10MiB。然 Git clone 卻回報下載了 215 MiB,比未壓縮的最新索引內容還大,且是壓縮後 tarball 的 二十倍之多

將 git 歷史記錄 shallow clone 或 squash 只是暫時解。且不論事實上 GitHub 指出它們不想支援大型儲存庫的 shallow clone,也不論 libgit2 尚未支援 shallow clone,實際上這作法仍未解決客戶端必須下載包含所有 crate 的整包索引。

教學式解說

將索引作為純文字檔案,透過 HTTP 開放出來。開放既有的索引佈局排列也夠用(就像從 raw.githubusercontent.com 看到的長相),不過以 HTTP 來說 URL scheme 可能可以簡化。

欲了解 crate 資訊和解析依賴,Cargo(或其他客戶端)可對每個依賴建立請求,並送往已知的 URL 以取得需要的資訊,例如:https://index.example.com/se/rd/serde。對各個依賴來說,客戶端也會繼續請求其依賴資訊, 遞迴循環,直到取得所有依賴(並快取)到本機端。

請求依賴檔案是有辦法平行化運作的,所以依賴解析最差情況下的延遲會限制在依賴樹的最大深度。實務上最差情況非常少見,因為依賴通常會多次出現在樹中,因此可以提前發現並增加平行化機會。且,若已有 lock 檔案,所有列在其中的依賴也都可平行化核對處理,

離線支援

此提議的解法完全保留了 Cargo 離線工作的功能。(連線的情況下)欲取得 crate 必然要下載足夠的索引來使用,之後所有資料會快取留作離線使用。

減少頻寬使用

Cargo 支援 HTTP/2,可有效率地處理多個相似的請求。

所有已獲取的依賴檔案可被快取,並透過條件式 HTTP 請求(EtagIf-Modified-Since 標頭)來避免重複下載未改變的檔案。

依賴的檔案易於壓縮,目前 rustc-ap-rustc_data_structures 內最大的檔案可用 Brotli 從 1MiB 壓縮到 26KiB。許多伺服器支援透明地提供預壓縮檔案(例如:可從帶有適當內容編碼標頭的 rustc-ap-rustc_data_structures.gz 提供 /rustc-ap-rustc_data_structures 的請求處理),因此索引可以保有高壓縮率但不必花費過多 CPU 以提供這些檔案。

就算是最糟的完整索引一個檔案一個檔案的下載狀況,比起 git clone 仍然使用更少頻寬(所有檔案分別壓縮加起來大小 39MiB)。

提供「增量式變更記錄」檔(在「未來展望」詳述)可以避免過多的條件式請求。

處理移除的 crate

有需要的話,本提議的解法可支援 crate 移除。當客戶端欲檢查一個已移除的 crate 的新鮮度,會向伺服器請求並獲得 404/410/451 HTTP 狀態。客戶端可以根據情況處理,並清理本機端的資料(甚至是 tarball 和原始碼已簽出的情況下)。

若客戶端對該移除 crate 不感興趣,就不會檢查之,但極大機率從來不會這樣做,也不會下載它。若主動清除已移除的 crate 快取資訊非常重要,則可以延伸「增量式變更記錄」來通知該移除。

缺點

  • crates-io 計畫替索引加上加密簽章,作為 HTTP 上額外一層保護。對 git 索引來說加密驗證非常直觀,不過簽署鬆散的 HTTP 索引可能蠻有挑戰性的。
  • 在還沒有增量式變更記錄前,一個基本的實作,需要許多請求來更新索引。和 git fetch 相比會有更高的延遲。然而,在一些早期的效能比較下,若 CDN 支援足夠(>60)多的平行請求,會比 git fetch 更快。對 Github 上的索引來說,Cargo 有一個捷徑會檢查 master 分支是否改變。若有增量式變更記錄檔,同樣的捷徑可以透過條件式 HTTP 請求該變更記錄檔來達成(例如,檢查 ETagLast-Modified)。
  • 此解法高效與否仰賴於建立多個平行小請求。支援 HTTP/2 的伺服器上做檢查是 HTTP/1.1 的兩倍快,但其實速度超越 HTTP/1.1 很合理。
  • 替代 registry 的功能目前已穩定,代表基於 git 索引協定也穩定且無法移除了。
  • 發送模糊搜尋索引的工具(例如 cargo add)可能需要發送數個請求,或是改用其他方法。URL 已全數正規化到小寫,所以不分大小寫的不需要額外的請求。

原理及替代方案

查詢 API

顯而易見的替代方案是建立可供查詢依賴解析的伺服器端 API(例如提供依賴清單就回傳一個 lockfile 或類似的東西)。然而,這會需要在伺服器端跑依賴解析。維護這種至關重要的動態 API 給幾乎所有 Rust 使用者每天使用,比其靜態檔案更難花費更高。

本提議之解法並不需要伺服器端任何邏輯。索引可以放在靜態檔案 CDN 上,且非常容易快取或鏡像。不需要修改索引如何取出。標準版本的索引可以繼續以 git 儲存庫形式維持完整的歷史記錄。這能保持與過往 Cargo 版本的相容性,並且第三方工具可以使用當前的索引格式。

從 rustup 提供初始索引

Rust/Cargo 的安裝可以打包初始版本的索引,這樣當 Cargo 運作時,就不需要從 git 下載整份索引檔,只需要做與種子版本差異更新。這個索引需要 rustup 更聰明地分別處理,避免在安裝或更新不同版本的 Cargo 時,造成重複下載索引多次。這會讓索引的下載和壓縮更好,讓做法可使目前的實作撐更久,但仍沒辦法解決索引不斷增長的問題。

本提議之解法更是伸縮自如,因為 Cargo 只要下載和快取「需要用到」的索引,未使用/棄用/垃圾 crate 不會造成任何花費。

Rsync

rsync 協定需要掃描並校驗原始碼與目標檔案的檢查碼異同,這需要許多不必要的 I/O,且需要 SSH 或在伺服器上運行的客製化系統服務(daemon),這限制了架設索引的選項。

先驅技術

https://andre.arko.net/2014/03/28/the-new-rubygems-index-format/

Bundler 曾經是預先下載完整索引,和 Cargo 一樣,直到它變得太大太大。然後它改用中心化的查詢 API,直到問題多到難以支援。然後就切換到增量式下載偏平檔案索引格式,和本提議解法相似。

未解決問題

  • 如何設定索引(包含替代 registry)該從 git 或新的 HTTP 下載?目前的語法使用 https:// URL 當作 git-over-HTTP。
  • 我們如何確保切換到 HTTP registry 不會導致 lock 檔案出現巨大差異?
  • 如何改寫當前的解析器,開啟平行取得索引檔案的功能?目前所有索引需要同時都可用,這杜絕了平行化的可能性。

實作可行性

目前已有一個經過測試的實作,是以簡單「貪婪」演算法來取得索引檔案,在 https://github.com/rust-lang/cargo/pull/8890 ,並且證實了不錯的效能,尤其是全新的建構。該實驗性實作的 PR 建議一個修改解析器的做法,以移除不必要的貪婪取得階段。

未來展望

增量式的 crate 檔案

Bundler 替每個獨立的依賴檔案使用僅允許附加(append-only)格式,以盡可能只增量下載新版本資訊。Cargo 的格式幾乎就是 append-only(除了 yank),所以如果單一檔案成長到是個問題,應該要版本修復它。然而,當前最大的 crate rustc-ap-rustc_data_structures 天天發佈新版本,每個版本增加 44 位元組(壓縮後),所以就算十年後它也才 190KB(壓縮後),看起來並沒有駭人到需要解決。

增量式變更記錄

截至目前,本方案在每次更新索引時,都必須重驗證每個索引檔案的新鮮度,即使許多檔案根本沒變。執行 cargo update 會導致索引更新,不過也有其他時機會導致更新,例如專案缺少 lockfile,或是添加了新的依賴。雖然 HTTP/2 pipeline 和條件式 GET 請求使得請求多個未改變檔案不錯有效率,但如果我們能避免那些無謂的請求,只請求有更改的檔案會更好。

一個解決方案是提供一個索引的概覽,讓客戶端可以快速確認本機端的索引檔是否過期。為了避免客戶端無謂的請求完整引樹快照,索引可以維護一個僅允許附加(append-only)的變更記錄。當改變發生(crate 版本發佈或 yank),記錄檔會附加新的一條記錄:epoch number(下面解釋之)、最後修改時間戳記、變更的 crate 名稱,未來需要也可添加其他額外資訊。

因為這個記錄檔只允許附加,所以客戶端可以利用 Range HTTP 請求漸增地更新它。客戶端不必下載完整的記錄檔來用,下載從任意一點到檔案結束這段即可,這用 Range 請求來達成簡單易懂。當在記錄裡(從最末端開始)找到一個 crate,並且其更動時間和本機快取一致,如此客戶端就不需要額外對該檔案發送 HTTP 請求。 當記錄檔成長過大,epoch number 遞增,記錄檔就可以重設到空白。即使在客戶端對該新記錄檔的 Range 請求合法的狀況下,epoch number 仍可讓客戶端有辦法偵測記錄檔是否有重設。

最終,這個 RFC 並沒有建議此方案,因為記錄檔恐導致程式碼變得十分複雜,且比起簡單的下載正面效益不大。若索引快照跟著 registry signing 一起實作了,此 RFC 的實作就可利用該快照機制當作變更記錄。

應付不一致的 HTTP 快取

索引並不需要將所有檔案合併產生一個快照。索引是一次更新一個檔案,並且只需保留部分更新排序。從 Cargo 的角度來看,各個依賴可允許獨立更新。

過期的快取僅在於新版本的 crate 使用了最新版本且剛發佈的依賴時,且該 crate 的快取失效得比其依賴早,才可能產生問題。Cargo 要求依賴需在可見的索引上有充分版本資訊,並且不會發佈任何「毀損」的 crate。

然而,CDN 快取總是有機會過期或失效的順序不一致。若 Cargo 偵測到索引的快取過期了(例如一個 crate 的依賴尚未出現在索引中),它可從該狀況復原,附加「快取破壞者」(如當前時間戳記)在 URL 上,以重新向索引請求檔案。就算 CDN 並不理會請求中的 cache-control: no-cache,這還是可靠能繞過過期快取的方法。