跳到內容

Loader API

載入器會發布一個 Loader 服務。外掛注入它,就能讀取並編輯設定檔中的項目:

import { Deferred, Effect, Layer } from "effect"
import { Graft, Plugin } from "@graftland/graft"
import { Entry, Loader, ModuleResolver } from "@graftland/loader"
const admin = Plugin.make({
name: "app/admin",
inject: [Loader.Loader],
apply: () =>
Effect.gen(function* () {
const loader = yield* Loader.Loader
const id = yield* loader.create({ name: "./plugins/cron.mjs" })
yield* loader.update(id, { config: { every: "5 minutes" } })
yield* loader.update("api", { disabled: true })
}),
})
const cron = Plugin.make({ name: "app/cron", apply: () => Effect.void })
const api = Plugin.make({ name: "app/api", apply: () => Effect.void })
const resolver = ModuleResolver.fromRecord({ "./plugins/cron.mjs": cron, api })
const entries = await Effect.runPromise(
Effect.gen(function* () {
const loaderFork = yield* Graft.plugin(Loader.plugin, [
{ id: "api", name: "api" },
])
yield* loaderFork.await
const fork = yield* Graft.plugin(admin)
yield* fork.await
const read = yield* Deferred.make<ReadonlyArray<Entry.Entry>>()
const probe = Plugin.make({
name: "app/probe",
inject: [Loader.Loader],
apply: () =>
Loader.Loader.pipe(
Effect.flatMap((loader) => Deferred.into(loader.entries, read)),
Effect.asVoid,
),
})
yield* Graft.plugin(probe)
return yield* Deferred.await(read)
}).pipe(
Effect.scoped,
Effect.provide(Layer.provideMerge(Graft.layer, resolver)),
),
)
entries[0]?.disabled // => true
entries[1]?.config // => { every: "5 minutes" }
成員 作用
entries 最新已持久化的目標項目。
changes 目前已持久化的目標,之後是已接受的目標,皆在執行期協調之前。
create(entry, options?) 新增項目,位置在根清單的末尾,或群組 parent 的 position。回傳其 id。
update(id, patch) 修改項目的部分欄位。
remove(id) 移除項目。
ready 等待目前的目標協調完成,且沒有任何 fork 處於 Loading 狀態。
retry 重試所有失敗的項目,並重新整理整棵樹中的 !env 值。

編輯會寫入檔案。Graft 會保留檔案中的註解、格式和 !env 標籤。

階段 保證
已接納 編輯進入了載入器有上限的工作佇列。在此之前呼叫端若被取消,編輯不會產生任何效果。
已儲存 檔案已寫入,目標已發布。編輯的效果在此完成。
已協調 外掛已完成更新。編輯完成並不代表這一點。請使用 ready。

編輯被接納後,取消呼叫端只會停止等待。載入器由 scope 管理的工作者會繼續儲存檔案並發布目標。

  • 每個呼叫端透過 Deferred 取得自己操作的結果。如果呼叫端已經離開,失敗也會連同完整的 cause 一併記錄。
  • 關閉載入器會停止它的工作者、結算尚未完成的呼叫端,並以 LoaderClosed 拒絕新的編輯。

執行期協調在另一個由 scope 管理的 fiber 中執行,可能會略過中間的目標。

你想要… 做法
等待目前的目標和載入中的 fork 在生命週期回呼之外使用 ready。
從生命週期回呼中提交編輯 可以提交,但絕不要在那裡等待 ready:它會等待回呼自己。
重新整理目前已儲存的目標 先 retry,再 ready。retry 在發布其請求之後回傳。
重新整理整棵樹的 !env loader.retry,或 loader.root.update(entries)(項目未變更時也會重新整理)。
持久化設定變更 使用一般的編輯方法。

ready 會等待目前的目標版本、已排隊和執行中的環境重新整理,以及所有載入中的後代。它把 Pending 和 Failed 的 fork 視為已完成,而外掛在提供者啟動後,仍可能短暫處於 Pending。因此 ready 不保證每個外掛都已啟用。核心的 fork.stable 屏障只描述目前的 fork 狀態,並不能取代這些載入器檢查。

關於 retry:

  • 當 retry 再次發布未變更的目標時,changes 也會發出。
  • 它會觸及生命週期、注入和群組包裝層之下的失敗外掛,並重新啟動失敗的外掛 fork。
  • 除非重新整理的值需要更新或重新載入,否則它會保留健康的實例和尚未完成的解析。
  • 一般編輯會重試已協調群組中失敗的項目,但不會重新整理未變更的健康子樹。

載入器會為它接受的每個目標保留一份凍結快照。

  • 在 API 編輯進入佇列之前,載入器會複製陣列和一般物件(包括中繼資料)。接受初始目標或重新整理後的目標時,也會複製它們。
  • 之後修改你傳入的物件,不會改變執行中的目標或已儲存的目標;要套用新值,請再提交一次編輯。
  • loader.entries 和 loader.changes 提供的是凍結的資料。
值 處理方式
陣列和一般物件 複製。共用的參照和環會保留在快照中,但環仍會驗證失敗。
類別實例(Env、Data 類別、Date 等) 保留原本的參照。請把它們當作不可變的設定來使用。
環境變數解析中共用的陣列和記錄 保持原樣,不展開成樹。身分快取只屬於單次執行。

快照複製、環偵測和設定比較也都能處理共用圖。比較時,每對一般容器只會走訪一次,不透明值沿用 Effect 的相等性,包括自訂的相等性實作。含環或不透明值的設定在比對 ID 時改用保守的後備方式,同名項目較多時仍可能需要平方級比較。

Graft 先寫入暫存檔,因此設定檔不會停留在寫到一半的狀態。

  1. 在同一個目錄中建立暫存檔:獨佔建立,權限模式為 0600。
  2. 保留目標的權限位元,以及 Graft 能讀到的 uid/gid。新檔案預設為 0600。
  3. 對檔案執行 fsync 並關閉。
  4. 以 rename 換入原位。
情況 行為
寫入失敗或被中斷 Graft 會移除暫存檔。如果清理也失敗,兩個失敗會一併保留。
擴充 ACL 和屬性 不複製。
斷電 不會對目錄執行 fsync,因此 Graft 不保證 rename 能保留下來。
解析或序列化失敗 以 StoreError 失敗並保留原始 cause。檔案不變。

Graft 在寫入之前,會解碼最終文件並與目標比對。

情況 行為
編輯變更或移除了錨點 受該編輯影響的別名會變成其先前值的獨立副本,其他別名保持原樣。
編輯透過別名群組變更子項目 Graft 會先建立本機副本。來源集合不會改變。
項目/群組結構上的別名 在收集並分配 ID 之前先具體化,因此匿名子項目會取得各自的 ID。
自動分配 ID Graft 計算一份獨立的邏輯目標,只具體化受插入影響的別名。一般外掛設定不會繼承產生的 ID。

Graft 會解析序列化後的 YAML 並與規劃的目標比對。如果兩者不同,它會回傳 StoreError 並讓檔案保持不變。

如果自 Graft 上次讀取之後有人編輯過檔案,Graft 會在套用編輯之前重新讀取。如果編輯所修改的欄位在檔案中也被修改過,以檔案為準:Graft 會丟棄該編輯並記錄一則警告。

Graft 也會在 rename 已備妥的暫存檔之前,再檢查一次目的地:

  1. 如果檔案已變更,Graft 會丟棄暫存檔、讀取新版本並重新套用編輯。
  2. 同欄位規則仍以原始快照為準,因此也能發現準備期間發生的變更。
  3. Graft 最多嘗試三次。如果檔案持續變更,它會回傳 StoreError,並且不提交該編輯。
  4. 清理失敗會停止重試,並保留其完整的 cause。

建立第一個檔案和自動分配 ID 也使用相同的檢查。