NEO.K / MSSP 開源專案考古016-promise-all
專案ECMAScript Promise.all / Promise.allSettled
授權BSD-3-Clause (V8, the implementation measured); MIT (Node.js)
檢視版本measured at run time
日期2026-08-16
來源upstream ↗

016 — Promise.all / Promise.allSettled:做完的工作被丟掉,而且沒有人被告知丟了多少

上游是 ECMAScript 內建的兩個 combinator(Promise.all), 實作為 V8(BSD-3-Clause),跑在 Node.js(MIT)。版本由執行時量出來。 這一則沒有任何檔案系統或網路操作——四個 setTimeout,全部在本機重現。

node src/main.mjs            # 兩個 combinator,同樣四個 promise
node src/main.mjs --strict   # 這個部署丟掉了付過錢的工作就 exit 1
node src/island_test.mjs     # 34 項檢查,全部直接跑真的內建函式,數字由它自己印

為什麼選它

同日的範例 016 主張合併方式本身是一個宣告單元——它宣告的是「已經成功的工作要怎麼辦」。

Promise.all 是那個宣告在上游最普遍、而且沒有地方可以寫下來的地方。它是每個人手指第一個打出來的東西,而它對已經回來的答案做的事,簽名上一個字都沒有。

原專案的結構地圖

四個成員,其中一個 reject:

    combinator            kept   ran        reasons
    Promise.all           0      a,b,c,d    1
    Promise.allSettled    3      a,b,c,d    1

兩邊四個成員都跑完了。 三個 fulfilled 的值真的存在過,而從那個 rejection 一個都拿不回來

對照組是這一則能不能說話的關鍵——同樣四個成員,沒有人 reject:

    Promise.all           4      a,b,c,d    0
    Promise.allSettled    4      a,b,c,d    0

一模一樣。 所以上面那個落差是一句關於失敗的話,不是關於這兩個函式的話。沒有這個對照組,「allSettled 不一樣」不構成任何具體主張。

第二個量測,跟昨天的考古 015 相反:

                             values the caller receives
    rejecting member removed  3   ["a:ok","c:ok","d:ok"]
    rejecting member present  0   rejected: b broke

把壞掉的成員移除,比留著讓它壞,拿到的還多。 而且輸入沒有變小——留著的那次四個成員都跑了,移除的那次三個。

第三、兩個成員 reject,抵達呼叫端的理由是 1 個。第二個失敗不是延後回報、不是報去別的地方,是沒有回報

第四、rejection 什麼都不取消。快的那個炸掉之後,慢的那個照樣 settle:

  after the rejection, members settled so far: b
  ...and 100ms later:                          b,slow - nothing was cancelled

MSSP 重切

集合 裡面是什麼
FMS 每個 combinator 保留什麼、浮出幾個理由,以及 units 對照
SCL 這個部署用哪一個,以及「丟掉做完的工作」在這裡是不是致命的
SMS 直接跑真內建函式的探針,每一個都記錄哪些成員真的執行了
TMS 一個 combinator 一個檔——各自宣告保留什麼,而且 import 任何東西都沒有
DMS 呼叫端最後拿到什麼、什麼照樣跑了,以及它已經問不到什麼

重切加的只有一件事:combinator 要宣告它保留什麼,而那份宣告用跑的驗。

第 3b 節是鑽孔——一個宣稱 KEEPS_WHAT_SUCCEEDED: true、實作卻是 Promise.all 的單元必須被抓到,而且誠實的兩個單元在同一支探針下宣告要成立。四個變異跑過,每一個都讓套件變紅(包括探針自己忘記記錄哪些成員跑過)。

什麼不適合拆

Promise.all 的行為不適合改。 一個必須全有或全無的交易要的正是它——三個寫入成功、一個失敗,這時候留下三個才是災難。allSettled 在 ES2020 進語言,正是因為沒有一個語義適合所有人

缺陷不在它拋棄成功的值,在於它拋棄的量沒有出現在任何地方。呼叫端拿到一個 reason,而「有三個值曾經存在」這件事沒有通道可以講。這跟昨天 os.walk 是同一句話的兩面:那邊是吞掉錯誤、結果看起來完整;這邊是保住錯誤、把結果整個丟掉。兩邊都只留下一個數字,而那個數字說不出發生過什麼。

這次沒有解決什麼

量得到但這次沒量: 真實程式裡 Promise.all 的 rejection 有多少比例伴隨著至少一個 fulfilled 成員;有多少呼叫端在 catch 之後真的重跑了整批。

這一則量不到: 任何一位呼叫端當初以為 Promise.all 會怎麼處理已經回來的答案。它量的是介面讓什麼通過,不是誰誤解了什麼——跟考古 015 同一句話。

還有一個沒做的: Promise.anyPromise.race 是同一族的另外兩個點,這一則沒有把它們納入比較。加進來需要新的探針,不是重讀既有輸出。

重切原始碼

FMS

FMS/architecture.json
{
  "name": "016-promise-all",
  "upstream": "ECMAScript Promise.all / Promise.allSettled, measured on the local V8",
  "what_is_being_examined": "Two built-in combinators over the same four promises, where one member rejects. What reaches the caller, and what the caller can no longer ask.",

  "the_finding": "Promise.all rejects with one reason and the fulfilled values are unreachable — while every member ran to completion. Removing the rejecting member returns three values; leaving it in returns none. Removing a broken member gives MORE than keeping it, which inverts what example 015 measured about os.walk and about its own sources.",

  "second_finding": "Two rejections produce exactly one reason. The second failure is not delayed or reported elsewhere; it is not reported.",

  "third_finding": "A rejection cancels nothing. The slow member settles afterwards, off to the side of a caller that has already moved on.",

  "the_discriminator_that_exists": "Promise.allSettled has been in the language since ES2020 and returns one entry per input, always, with the outcome attached to the item. It is not the default anybody reaches for — the same family as archaeology 011's `d[k] is d[k]` and archaeology 015's empty-versus-missing row count.",

  "combinators": {
    "Promise.all":        {"keeps_what_succeeded": false, "reasons_surfaced": 1},
    "Promise.allSettled": {"keeps_what_succeeded": true,  "reasons_surfaced": "all"}
  },

  "the_control": "The same four members with nothing rejecting. Both combinators then carry the same information, so the difference is a statement about failure and not about the two functions.",

  "sets": {
    "FMS": "this file: what each combinator keeps, what it surfaces, and the units map",
    "SCL": "which combinator this deployment runs, and whether discarding completed work is fatal here",
    "SMS": "the probes that run the real built-ins, each recording which members actually executed",
    "TMS": "one file per combinator — each declares what it keeps, and imports nothing",
    "DMS": "what the caller ends up holding, what ran anyway, and what it can no longer ask"
  },

  "units": {"TMS/combinators": ["all.mjs", "all_settled.mjs"]},

  "the_recut_adds": "A combinator declares what it keeps, and the declaration is verified by running it. Section 3b is the drill: a unit claiming to keep what succeeded while implemented with Promise.all must be caught."
}

SCL

SCL/policy.json
{
  "deployment": "fan-out-fetch",
  "combinator": "Promise.all",
  "discarding_completed_work_is": "fatal",
  "why": "This deployment fans out to several backends and each one costs a request that already went out. Silently dropping the answers that came back is the thing it must not do, so it names that and exits non-zero rather than treating the rejection as the whole story.",
  "not_a_general_rule": "A transaction that must be all-or-nothing wants exactly Promise.all's behaviour. The policy is a deployment's, not the combinator's."
}

SMS

SMS/upstream.mjs
// Probes that run the real V8 built-ins. Nothing here is simulated.
//
// Every probe records which members actually executed, because the whole
// finding is that work runs and is then thrown away.

// A member that resolves or rejects after `ms`, appending its name to `ran`
// the moment it settles. `ran` is how we know the work happened.
export function member(ran, name, ms, { rejects = false } = {}) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      ran.push(name);
      if (rejects) reject(new Error(`${name} broke`));
      else resolve(`${name}:ok`);
    }, ms);
  });
}

export async function withAll(build) {
  const ran = [];
  try {
    const values = await Promise.all(build(ran));
    return { ran, settled: true, values, reason: null };
  } catch (raised) {
    return { ran, settled: false, values: null, reason: raised.message };
  }
}

export async function withAllSettled(build) {
  const ran = [];
  const results = await Promise.allSettled(build(ran));
  return { ran, results };
}

// One rejecting member among three that fulfil.
export const oneRejects = (ran) => [
  member(ran, "a", 10), member(ran, "b", 20, { rejects: true }),
  member(ran, "c", 30), member(ran, "d", 40),
];

// The same input with the rejecting member taken out — the island test.
export const rejectingMemberRemoved = (ran) => [
  member(ran, "a", 10), member(ran, "c", 30), member(ran, "d", 40),
];

// The control: the same shape and the same count, and nothing rejects. Without
// it, "all and allSettled differ" is not a statement about failure.
export const noneReject = (ran) => [
  member(ran, "a", 10), member(ran, "b", 20),
  member(ran, "c", 30), member(ran, "d", 40),
];

// Two rejecting members. How many reasons reach the caller?
export const twoReject = (ran) => [
  member(ran, "b", 10, { rejects: true }), member(ran, "e", 20, { rejects: true }),
];

// A fast rejection alongside a slow fulfilment: does the rejection stop it?
export const fastRejectSlowMember = (ran) => [
  member(ran, "b", 5, { rejects: true }), member(ran, "slow", 60),
];

export const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const runtime = () => `${process.release?.name ?? "node"} ${process.version} (V8 ${process.versions.v8})`;

TMS

TMS/combinators/all.mjs
// Promise.all, wrapped as a declaring unit.
//
// It declares that it does NOT keep what succeeded. The declaration is checked
// by running it (island test section 3b), not by reading this line.
export const COMBINATOR = "Promise.all";
export const KEEPS_WHAT_SUCCEEDED = false;
export const REASONS_SURFACED = 1;

export async function combine(promises) {
  try {
    return { kept: await Promise.all(promises), reasons: [] };
  } catch (raised) {
    return { kept: [], reasons: [raised.message] };
  }
}
TMS/combinators/all_settled.mjs
// Promise.allSettled, wrapped as a declaring unit.
//
// The outcome travels with each item — `{status, value}` or `{status, reason}` —
// which is why it can keep the fulfilled values without them going anonymous.
export const COMBINATOR = "Promise.allSettled";
export const KEEPS_WHAT_SUCCEEDED = true;
export const REASONS_SURFACED = Infinity;

export async function combine(promises) {
  const results = await Promise.allSettled(promises);
  return {
    kept: results.filter((r) => r.status === "fulfilled").map((r) => r.value),
    reasons: results.filter((r) => r.status === "rejected").map((r) => r.reason.message),
  };
}

DMS

DMS/report.mjs
// What a person is shown. The columns exist so that "what the caller holds"
// and "what actually ran" can never be read as the same number.
const pad = (value, width) => String(value).padEnd(width);

export function comparison(rows) {
  const lines = ["    combinator            kept   ran        reasons"];
  for (const row of rows) {
    lines.push(`    ${pad(row.name, 21)} ${pad(row.kept, 6)} ${pad(row.ran, 10)} ${row.reasons}`);
  }
  return lines.join("\n");
}

export function removalTable(removed, kept) {
  return [
    "                             values the caller receives",
    `    rejecting member removed  ${removed}`,
    `    rejecting member present  ${kept}`,
  ].join("\n");
}

export function settled(results) {
  const lines = ["    status     value or reason"];
  for (const result of results) {
    lines.push(`    ${pad(result.status, 10)} ${result.status === "fulfilled" ? result.value : result.reason.message}`);
  }
  return lines.join("\n");
}

root

island_test.mjs
// The island test, run against the real built-ins.
//
//   node src/island_test.mjs
//
// Section 3b is the drill: a combinator that DECLARES it keeps what succeeded
// while being implemented with Promise.all must be caught by running it.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import * as upstream from "./SMS/upstream.mjs";
import * as all from "./TMS/combinators/all.mjs";
import * as allSettled from "./TMS/combinators/all_settled.mjs";

const here = path.dirname(fileURLToPath(import.meta.url));
const FMS = JSON.parse(fs.readFileSync(path.join(here, "FMS", "architecture.json"), "utf8"));
const failures = [];
let ran = 0;
const check = (label, ok, detail = "") => {
  ran += 1;
  process.stdout.write(`  ${ok ? "PASS" : "FAIL"}  ${label}${detail ? ` - ${detail}` : ""}\n`);
  if (!ok) failures.push(label);
};
const say = (line = "") => process.stdout.write(`${line}\n`);

say(`\n  ${upstream.runtime()}`);

say("\n== 1. each combinator is an island, and FMS matches the tree");
for (const [unit, declared] of Object.entries(FMS.units)) {
  const dir = path.join(here, ...unit.split("/"));
  const onDisk = fs.readdirSync(dir).filter((n) => n.endsWith(".mjs")).sort();
  check(`${unit}: FMS declares what is on disk`,
    JSON.stringify(onDisk) === JSON.stringify([...declared].sort()),
    `disk ${onDisk.join(", ")} | FMS ${[...declared].sort().join(", ")}`);
  for (const file of onDisk) {
    const body = fs.readFileSync(path.join(dir, file), "utf8");
    check(`${unit}/${file} imports nothing at all`, !/^\s*import\s/m.test(body));
  }
}
check("both combinators declare what they keep",
  [all, allSettled].every((m) => typeof m.KEEPS_WHAT_SUCCEEDED === "boolean"));

say("\n== 2. one member of four rejects");
const ranAll = [];
const viaAll = await all.combine(upstream.oneRejects(ranAll));
await upstream.wait(80);
check("Promise.all kept nothing", viaAll.kept.length === 0);
check("but all four members ran to completion", ranAll.length === 4, ranAll.join(","));
check("so three fulfilled values existed and none is reachable",
  ranAll.filter((n) => n !== "b").length === 3 && viaAll.kept.length === 0);
check("exactly one reason surfaced", viaAll.reasons.length === 1, viaAll.reasons.join(" | "));

const ranSettled = [];
const viaSettled = await allSettled.combine(upstream.oneRejects(ranSettled));
check("Promise.allSettled kept three values", viaSettled.kept.length === 3, viaSettled.kept.join(", "));
check("and surfaced the failure as well", viaSettled.reasons.length === 1, viaSettled.reasons.join(" | "));
check("the two disagree by three values on identical input",
  viaSettled.kept.length - viaAll.kept.length === 3);

say("\n== 3. the control - the same four members, nothing rejecting");
const cleanAll = await all.combine(upstream.noneReject([]));
const cleanSettled = await allSettled.combine(upstream.noneReject([]));
check("Promise.all keeps four", cleanAll.kept.length === 4);
check("Promise.allSettled keeps four - the control", cleanSettled.kept.length === 4);
check("with nothing rejecting they carry the same information",
  cleanAll.kept.length === cleanSettled.kept.length && cleanAll.reasons.length === cleanSettled.reasons.length);
check("so section 2's gap is about failure, not about the two functions",
  cleanSettled.kept.length - cleanAll.kept.length === 0 && viaSettled.kept.length - viaAll.kept.length === 3);

say("\n== 3b. DRILL - a combinator that overclaims must be caught by running it");
const liar = {
  COMBINATOR: "drill-liar",
  KEEPS_WHAT_SUCCEEDED: true, // the declaration
  async combine(promises) {   // the implementation, which does not
    try { return { kept: await Promise.all(promises), reasons: [] }; }
    catch (raised) { return { kept: [], reasons: [raised.message] }; }
  },
};
async function keepsWhatSucceeded(unit) {
  const ran = [];
  const result = await unit.combine(upstream.oneRejects(ran));
  await upstream.wait(80);
  return result.kept.length > 0 && ran.length > result.kept.length;
}
check("the drill unit declares it keeps what succeeded", liar.KEEPS_WHAT_SUCCEEDED === true);
check("running it says otherwise", (await keepsWhatSucceeded(liar)) === false);
check("so the declaration is refused", (await keepsWhatSucceeded(liar)) !== liar.KEEPS_WHAT_SUCCEEDED);
check("and the honest unit's declaration holds under the same probe",
  (await keepsWhatSucceeded(allSettled)) === allSettled.KEEPS_WHAT_SUCCEEDED);
check("as does Promise.all's", (await keepsWhatSucceeded(all)) === all.KEEPS_WHAT_SUCCEEDED);

say("\n== 4. removing the rejecting member gives more than keeping it");
const removed = await upstream.withAll(upstream.rejectingMemberRemoved);
const present = await upstream.withAll(upstream.oneRejects);
await upstream.wait(80);
check("removed (island test): three values", removed.values?.length === 3, JSON.stringify(removed.values));
check("present and rejecting: none", present.values === null && present.reason !== null, present.reason ?? "");
check("removing it yields MORE than keeping it", (removed.values?.length ?? 0) > 0);
check("and it is not a smaller input - four members ran either way",
  present.ran.length === 4 && removed.ran.length === 3, `${present.ran.join(",")} | ${removed.ran.join(",")}`);

say("\n== 5. two rejections, one reason");
const two = await upstream.withAll(upstream.twoReject);
await upstream.wait(60);
check("both rejecting members ran", two.ran.length === 2, two.ran.join(","));
check("reasons that reached the caller: 1", two.settled === false && typeof two.reason === "string");
check("the second failure is not reported anywhere", two.ran.length - 1 === 1);

say("\n== 6. a rejection cancels nothing");
const fast = await upstream.withAll(upstream.fastRejectSlowMember);
const atRejection = [...fast.ran];
await upstream.wait(120);
check("at the moment of rejection, the slow member had not settled", atRejection.length === 1, atRejection.join(","));
check("it settled afterwards regardless", fast.ran.length === 2, fast.ran.join(","));
check("nobody was waiting for it", fast.values === null);

say("\n== 7. the discriminator exists and is not the default");
check("Promise.allSettled is present in this runtime", typeof Promise.allSettled === "function");
const per = await upstream.withAllSettled(upstream.oneRejects);
check("it returns one entry per input, always", per.results.length === 4);
check("and the outcome is attached to the item, not beside the batch",
  per.results.every((r) => r.status === "fulfilled" ? "value" in r : "reason" in r));
check("which is what makes the kept values safe to keep",
  per.results.filter((r) => r.status === "fulfilled").length === 3
    && per.results.filter((r) => r.status === "rejected").length === 1);

say("");
if (failures.length > 0) {
  say(`  ${failures.length} FAILED: ${failures.join(" | ")}`);
  process.exitCode = 1;
} else {
  say(`  ${ran} checks passed - every probe ran the real built-ins`);
}
main.mjs
// Two built-in combinators over the same four promises, one of which rejects.
//
//   node src/main.mjs            what each one keeps, and what ran anyway
//   node src/main.mjs --strict   exit 1 if this deployment discarded completed work
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import * as report from "./DMS/report.mjs";
import * as upstream from "./SMS/upstream.mjs";
import * as all from "./TMS/combinators/all.mjs";
import * as allSettled from "./TMS/combinators/all_settled.mjs";

const here = path.dirname(fileURLToPath(import.meta.url));
const POLICY = JSON.parse(fs.readFileSync(path.join(here, "SCL", "policy.json"), "utf8"));
const say = (line = "") => process.stdout.write(`${line}\n`);

const UNITS = Object.fromEntries([all, allSettled].map((m) => [m.COMBINATOR, m]));

async function through(unit, build) {
  const ran = [];
  const result = await unit.combine(build(ran));
  await upstream.wait(80); // let every member settle, including the ones nobody waited for
  return { name: unit.COMBINATOR, kept: result.kept.length, ran: ran.join(","), reasons: result.reasons.length };
}

async function main(argv) {
  say(`\n  ${upstream.runtime()}`);
  say(`  ${POLICY.deployment}: combine with ${POLICY.combinator}\n`);

  say("  one member of four rejects:");
  const rows = [await through(all, upstream.oneRejects), await through(allSettled, upstream.oneRejects)];
  say(report.comparison(rows));
  say("\n  Four members ran under both. One keeps three values, the other keeps none.\n");

  say("  the control - the same four members, none rejecting:");
  const clean = [await through(all, upstream.noneReject), await through(allSettled, upstream.noneReject)];
  say(report.comparison(clean));
  say("\n  Identical. The difference is a statement about failure, not about the two functions.\n");

  const removed = await upstream.withAll(upstream.rejectingMemberRemoved);
  const present = await upstream.withAll(upstream.oneRejects);
  say(report.removalTable(
    `${removed.values?.length ?? 0}   ${JSON.stringify(removed.values ?? [])}`,
    `${present.values?.length ?? 0}   rejected: ${present.reason}`));
  say("\n  Removing the broken member gives MORE than keeping it.\n");

  const two = await upstream.withAll(upstream.twoReject);
  await upstream.wait(60);
  say(`  two members reject, reasons that reach the caller: 1 - ${two.reason}`);

  const fast = await upstream.withAll(upstream.fastRejectSlowMember);
  say(`  after the rejection, members settled so far: ${fast.ran.join(",")}`);
  await upstream.wait(100);
  say(`  ...and 100ms later:                          ${fast.ran.join(",")} - nothing was cancelled\n`);

  const details = await upstream.withAllSettled(upstream.oneRejects);
  say("  what allSettled hands back instead:");
  say(report.settled(details.results));
  say(`    ${details.results.length} entries for ${details.results.length} inputs, always\n`);

  const unit = UNITS[POLICY.combinator];
  const discarded = !unit.KEEPS_WHAT_SUCCEEDED;
  if (argv.includes("--strict") && discarded && POLICY.discarding_completed_work_is === "fatal") {
    say(`  --strict: ${POLICY.combinator} discarded work this deployment paid for, and that is fatal here`);
    return 1;
  }
  return 0;
}

process.exitCode = await main(process.argv.slice(2));