ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Windmill npm 代理的缓存分层设计:packument 与 tarball 内容应该存放在哪一层

Windmill npm 代理的缓存分层设计:packument 与 tarball 内容应该存放在哪一层 Windmill npm 代理的缓存分层设计packument 与 tarball 内容应该存放在哪一层【免费下载链接】windmillOpen-source developer platform to power your entire infra and turn scripts into webhooks, workflows and UIs. Fastest workflow engine (13x vs Airflow). Open-source alternative to Retool and Temporal.项目地址: https://gitcode.com/GitHub_Trending/wi/windmillWindmill 的windmill-api-npm-proxy模块为前端的 raw app 包安装器和脚本编辑器的类型获取ATA代理私有 npm registry 的请求。本文基于仓库内的设计文档 docs/npm-proxy-storage.md 与实现代码 backend/windmill-api-npm-proxy/src/lib.rs、backend/windmill-api-npm-proxy/src/store.rs完整讲解为什么 packument 留在内存、tarball 派生内容落盘并按三层级本地磁盘 → 实例对象存储 → registry组织以及磁盘缓存如何处理驱逐、原子发布、ENOSPC 降级和恶意归档等一系列内存缓存不会遇到的失败模式。读完你可以掌握按数据形状选存储层的缓存设计方法以及一个可直接参考的分层缓存实现细节。背景两个话痨的浏览器端消费者npm 代理服务的对象是实例所配置的私有 registry两个消费方都在浏览器侧且天然话痨raw app 编辑器的包安装器解析依赖并下载每个包脚本编辑器的类型获取ATA打开一个脚本会先请求包的完整文件树再逐一请求其中每个.d.ts文件——对types/lodash这类包一个归档就会引发数百次请求。在设计改造之前代理缓存的全部内容都在 API 进程内存里缓存预算TTL内容PACKAGE_JSON_CACHE64 MiB60 sregistry 文档packumentTARBALL_CACHE256 MiB300 s每个包版本压缩归档、文件列表、保留的.d.ts与 manifest约 320 MiB 的稳态占用外加一次读取在构建映射时缓存尚未称量的瞬态开销在途读取数 ×RETAINED_BYTES。在 2 GB 的 API 容器上仅稳态就占 16%而且瞬态随请求并发线性增长、不受任何预算约束。这就是本次设计要解决的问题。核心设计被缓存的两种东西不是同一种形状设计文档把整个方案浓缩为一句话The two things being cached are not the same shape。Packumentregistry 文档体积小npm 缩写形式下 10–100 KB、热点高、可变发新版本文档就变。它存在的意义是省一次 registry 往返因此把它挪到一个自身就花一次往返的存储里毫无收益。它该留在内存里配短 TTL 和小预算。包归档tarball体积大10 KB–50 MB、会话之间是冷的、不可变已发布版本的 tarball 永不变且随时可以从 registry 重新拉取。这是典型的 blob 形状数据内存是错误的一层。由此得出决策packument 留内存归档派生内容落盘并在配置了实例对象存储object store时由对象存储兜底。决策一存展开后的文件而不是存归档把 tarball 本身当作缓存单元是最直觉的做法但也是错的每个/file请求仍然要下载并解压整个归档才能取到单个.d.ts。文档中举的例子是aws-sdk2.1692.0——每个声明文件都要付出 50 MB 的解压代价而 ATA 会要 442 个声明文件。正确的做法是缓存端点真正提供的东西也就是当前代码选择保留的内容。落盘目录结构为cache root/npm_proxy/registry hash/package/version/ manifest.json 文件列表 解析出的入口点 files/path 每个保留的 .d.ts 与 package.json源码中这个结构由 store.rs 的package_dir构造其中 cache root 复用windmill_common::worker::ROOT_CACHE_DIR见 worker.rs形如WINDMILL_DIR/cache/。/filetree端点因此只需读一个很小的manifest.json/file只需读一个小文件——请求路径上完全没有解压也没有大块内存常驻。保留规则manifest 加.d.ts保留规则之所以恰好是manifest 加.d.ts因为那就是ata/index.ts读取的内容treeToDTSFiles过滤到.d.ts调用方循环再逐个取依赖的package.json。归档里其余条目只是走过去。对集合外路径的请求依然可用按需把归档走一遍lib.rs 中get_package_file的read_one_entry分支。实现里这组边界在 lib.rs 以常量固定下来值得逐一理解其为什么常量值语义MAX_INFLATED_BYTES1 GiB单个归档解压总量上限。远高于任何真实包next解出约 184 MB / 8.5k 文件限的是滥用而非大小策略——包永远不会因太大被拒MAX_ENTRIES100 000单个归档条目数上限超出即拒绝RETAINED_ENTRY_BYTES4 MiB单个保留条目上限超出则回到按需读取路径MANIFEST_BYTES8 MiBmanifest 单独的上限比条目上限宽松因为它是唯一缺失会改变答案而非速度的文件RETAINED_BYTES96 MiB单包保留总预算已知最重的aws-sdk约 51 MB / 442 个声明文件RETAINED_PATH_BYTES32 MiB文件列表字节上限超了要拒绝而非截断——缺条目的文件树是错误答案。next约 8.5k 路径 / 1 MBDISK_BLOCK_BYTES4096磁盘记账的最小单位对应每个小文件至少算一个块worth_retaining的判定只认package.json和*.d.tslib.rs#L239-L241条目超出预算时不写盘、不报错只是不保留包仍可服务——这与文件列表超预算必须拒绝形成刻意区分前者让缓存有界而包仍可用后者防止文件树被静默截断。决策二三层级本地磁盘 → 对象存储 → registry分层顺序直接沿用 windmill-worker/src/global_cache.rs 里 worker 依赖缓存已有的模式本地磁盘优先快、每副本一份容器重启即失效本地未命中查实例对象存储命中后回填磁盘。跨副本共享跨重启存活两者都未命中才回 registry随后同时回填磁盘和对象存储。对象存储层存的是展开子集的 tar而不是逐文件对象该子集小KB 到几 MB而非 50 MB 的完整归档每个包版本只需一次 PUT、一次 GET而不是数百次并且正好匹配build_tar_and_push/pull_from_tar这对已有的函数。在 store.rs 中这套分层的具体形态对象键由object_keystore.rs#L70-L77生成形如npm_proxy/registry hash/package/version.tar拉取pull_from_object_storestore.rs#L282把对象流式写到 scratch 目录下的临时文件再在 blocking 线程里解包、校验 manifest 存在、修正 mtime 后publish_dir到目标解包结果没有 manifest 就当作未命中并告警绝不发布一棵没人读得动的树推送push_to_object_storestore.rs#L387把包目录在临时文件里打成 tar 再分块上传用信号量限制同时在途的上传数进程级一次一个、在途分片数为UPLOAD_PARTS_IN_FLIGHT 2store.rs#L33-L34——单位是分片数而不是排队字节数避免慢存储让整个 tar 都悬在上传任务里。整条推送是 best-effort 且不在请求关键路径上请求发出来时它来自的那个请求已经被服务了。cached_packagelib.rs#L937-L1030把三层串起来注意它的取序细节磁盘命中直接返回、不发任何网络请求对象存储未命中是正常路径对象存储读错误只记 warn 并继续回 registry——读不了的缓存是去拉取的理由不是失败的理由。决策三缓存键以 registry 作用域隔离缓存键必须包含 registry 标识而不只是包名和版本npmrc实例设置可以被改成指向另一个 registry而那个 registry 可能用相同的名字和版本提供不同的工件。实现是把解析后的 registry URL 哈希进路径——store.rs#L79-L83 的registry_key取 SHA-256 的前 8 字节十六进制。哈希进键只是一半。设计文档特别强调了另一半未命中时只解析一次设置并且用同一个快照去生成键、拉 packument、拉 tarball。如果解析两次设置在两次之间被改掉而 tarball 来源校验只比对 host同一台主机上的两个仓库就会把第二个 registry 的文件写到第一个 registry 的键下面——由于不可变性错误内容会被永久保留。源码中这一点落在cached_package的注释与结构里先get_npm_registry取一次快照之后一律走fetch_package_json_fromlib.rs#L319-L337传入已解析的 registry不再二次读设置。registry 的解析顺序get_npm_registry先读全局设置npmrcNPMRC_SETTING用parse_npmrc_registry解析失败则回退npm_config_registryNPM_CONFIG_REGISTRY_SETTING支持url:_authTokentoken内联鉴权格式。/config端点据此向浏览器端报告registry_configured让本来直连公共 npm CDN 的安装器知道要改道走代理。不可变的推论是归档各层不需要 TTL只有负责发现新版本的 packument 缓存需要 TTL当前为 60 s见下文最终状态。复用的既有机制设计文档强调这里几乎没有新机器仓库中这些部件都已存在windmill_object_store::get_object_store() - OptionArcdyn ObjectStore实例对象存储访问器backend/windmill-object-store/src/lib.rs。返回Option恰好就是未配置对象存储的分支。windmill_common::worker::extract_tarworker.rs#L1461与atomic_publish_dirworker.rs#L1446处理解包与并发半展开目录问题后者在 worker 侧已有惊群thundering-herd测试。windmill_common::worker::ROOT_CACHE_DIR磁盘缓存根的既有约定worker.rs#L840。global_cache的load_cache/save_cache/build_tar_and_push/pull_from_targlobal_cache.rs#L13、#L89、#L155、#L273worker 形态的磁盘优先、对象存储兜底模式因它们位于windmill-worker要么把共享部分挪进windmill-common要么代理自带一个小而全的等价实现——当前实现选择了后者store.rs自包含了拉取/推送/清扫逻辑。CE 版拿不到对象存储层windmill-object-store整体编译在#[cfg(feature parquet)]之下且所有调用点都门控在#[cfg(all(feature enterprise, feature parquet))]——在 store.rs 里可以看到pull_from_object_store的非 EE 分支直接Ok(None)视为未命中而 Cargo.toml 中parquetfeature 透传给windmill-object-store/parquet。实例对象存储因此是 EE 能力。这一点在这里比通常更重要最可能配置私有 npm registry 的用户是自托管用户而其中相当一部分用 CE。磁盘层对他们是整个功能本身而不是锦上添花。磁盘必须单独工作得足够好对象存储必须是叠加其上的纯增量。磁盘有而内存没有的失败模式落盘带来一整类内存缓存没有的失败模式store.rs的注释把每一种都写成了设计决策驱逐清扫把总量压回 2 GiBDISK_BYTESstore.rs#L25按最近最少使用优先限频为每 10 分钟SWEEP_INTERVAL_SECS 600或累计写入 256 MiBSWEEP_AFTER_WRITTEN先到为准。计量的是已分配块而非文件长度types/lodash这类海量小声明文件的包每个文件都占一个块按长度量会显得几乎免费measure 在 Unix 上按blocks() * 512累加。新鲜度用 mtime且要显式打标缓存卷通常以relatime挂载读取不推进访问时间建立在 atime 上的 LRU 会悄悄退化成 FIFO。所以缓存命中时由touchstore.rs#L127-L134显式改写 manifest 的 mtime清扫读取包内最新的 mtime 作为最近使用时间。从对象存储拉回解包的树还会再 touch 一次 manifest否则unpack恢复的是别的机器上首次填充时的 mtime刚被请求的包会因时间最旧而第一个被驱逐。ENOSPC 与只读缓存目录降级而不失败所有写都是 best-effort写失败时对该请求把归档再走一遍、解出内存映射PackageFiles::Memorylib.rs#L924-L930。只在失败路径上才建内存映射——如果磁盘写成功的同时也建一份等于把本模块要消灭的每请求堆占用放了回来。错误区分也很讲究解包本身失败!disk_failed会原样上抛因为归档的问题跟缓存无关只有归档没问题但盘写不进去才降级。残骸与活着的写者相区分被杀的写者留下.tmp.uuid目录没人会读它。清扫只在残骸超过一小时SCRATCH_STALE_SECS 3600后删除它且永不把 scratch 目录当作驱逐候选并发冷填充恰是清扫最可能运行的时机在写者脚下删掉 scratch 会发布一个缺失其已写内容的包。is_scratch只认UUID 后缀的精确形状store.rs#L543-L551——版本号是 registry 提供的1.0.0-alpha.tmp.1这种版本字符串不能被误判为残骸而永远重新拉取。测试a_scratch_directory_still_being_written_survives_a_sweep与the_sweep_drops_debris_and_the_least_recently_usedstore.rs#L697、#L662分别钉住了这两条行为。活路径上只有整包或什么都没有发布是把写完整的临时目录一次rename到位PendingPackage::publish→publish_dirstore.rs#L206-L273部分树永远不会看起来完整。rename 丢失只有一种情况算成功目标已存在 manifest意味着另一个写者发布了同样的不可变内容。目标没有 manifest 则是残骸——被detach 挪走同样用 rename再替换而不是原地递归删除。驱逐是同一个 rename 的反方向。流式传输凡随包大小增长的东西都离开运行时拉取走临时文件解包、计量、发布全在 blocking 线程推送在磁盘上打 tar、上传按在途分片数设限。把任何一侧缓冲进内存、或在运行时 worker 上走一棵上千声明文件的树都会放回来本模块要消灭的东西。恶意归档不是坏磁盘条目路径试图逃出其目录时在两个 sink 都未触碰之前就被拒绝is_safe_relativestore.rs#L108-L112extract_to在 lib.rs#L824-L830 调用——它不能像不可写的缓存那样降级。包名/版本段也做了路径逃逸防护escape_segment把/、\转成%2F全点段..转成%2Estore.rs#L85-L93测试a_traversal_cannot_be_a_package_name验证了一个叫..的包名不会把目录解析到父级。驱逐与读取竞争包可能在查到目录与读 manifest之间被扫掉因此 manifest 缺失按未命中处理并重新填充而不是报错——get_package_filetree 的两次重试循环就是为此第二次尝试读的是刚写下的目录有界收敛。文件缺失则照常落到按需走归档路径。递归删除没有原子点所以活路径上一律不删remove_dir_all按 readdir 顺序 unlink中途被杀会在原地留下永久半包。两种半包状态各有灾难文件没了、manifest 还在永远被读作完整包。manifest 让查找短路对象存储永远不会被咨询去修复它它列出的每个声明文件都落到按需走归档路径——每请求重新拉取并解压归档manifest 没了、文件还在读作未命中但rename拒绝非空目标目录于是之后发布的每棵树都被让位给残骸而丢弃路径永远返回 500而桶里其实躺着一份有效副本。所以发布与驱逐都整目录renamedetach把候选挪到 scratch 名之下、由 guard 在 Drop 时删掉清扫如果连 detach 都做不到就把候选原样留下跳过——上限是下一次清扫会复查的界而被删一半的包不是。对应测试a_destination_left_without_a_manifest_is_replacedstore.rs#L736专门验证了无 manifest 的目的地会被替换而不是被礼让。实现中的两条定型结论设计文档 Status 一节记录了实现落地后的状态以及实现过程中敲定的两个要点改它之前值得记住温包不得花一次往返。registry 来自设置解析packument 只在未命中时拉取。若为了拿到 tarball URL 而先拉 packument一个完全缓存住的包仍会每分钟打一次 registry。对应cached_package的取序本地有 manifest 就touch后直接返回网络代码根本不执行。manifest 豁免于保留预算。next把它的package.json排在第 3748 位若预算先被前面的声明文件花光入口点就会退回默认值——那是错误答案不是慢一点而已。lib.rs#L853-L873 中 manifest 走独立上限、不计入retained预算测试the_entry_point_survives_a_spent_budgetlib.rs#L1200钉住了预算花光后入口点仍在的行为。最终状态归档缓存已从内存移除提取时每个条目直接流式写入包目录一次读取只持有一个条目而非整个保留集堆里剩下的只有PACKAGE_JSON_CACHE预算从设计初期的 64 MiB 收敛为当前的16 MiBPACKAGE_JSON_CACHE_BYTESlib.rs#L182-L187TTL 60 s单分片、按字节称重的quick_cache——单分片是刻意的分片缓存会把预算除以分片数静默拒绝最重的那些文档。文档明确列出的未决项是single flight同一包的并发未命中会各自拉取、解压。按 rename 发布加上每次拉取独立 scratch 目录让这只是浪费而非不安全但仍是浪费。被考虑过的替代方案只降预算常量当初是按吞吐量定的没有考虑 2 GB 容器。把TARBALL_CACHE_BYTES砍到 64 MiB、PACKAGE_JSON_CACHE_BYTES砍到 16 MiB 是四行改动、无新失败模式代价只是更大的包更频繁地重新拉取。它修不了瞬态瞬态随并发而非预算增长也活不过重启、无法跨副本共享。文档的结论是如果现在不值得做存储工作就选它——它是同一权衡的严格缩小版。小结这套设计的可迁移结论是按数据形状分层小、热、可变的 packument 留内存加短 TTL大、冷、不可变的归档内容落盘必要时上对象存储缓存端点真正服务的单元展开后的 manifest 与.d.ts而不是上游传输单元tarball让请求路径零解压键与作用域一致registry 哈希进键、单次设置快照贯穿未命中全程磁盘失败模式逐一显式处理块计量驱逐、mtime LRU、ENOSPC 降级、scratch 残骸、rename 原子发布、流式 IO、恶意路径拒绝每一种都有对应测试分层可独立退化CE 只有磁盘层也完整可用对象存储是纯增量。继续深入可以从 lib.rs 的六个端点路由/config、/metadata、/resolve、/filetree、/file、/tarball与 store.rs 的测试模块读起resolve端点的 npm 版本范围归一化normalize_comparator_set、widen_bare_partiallib.rs#L523-L590及其覆盖^/~/连字符区间/v前缀/裸部分版本等 npm 与semvercrate 语义差异的测试是这个模块另一个值得细看的部分。【免费下载链接】windmillOpen-source developer platform to power your entire infra and turn scripts into webhooks, workflows and UIs. Fastest workflow engine (13x vs Airflow). Open-source alternative to Retool and Temporal.项目地址: https://gitcode.com/GitHub_Trending/wi/windmill创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表