Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions actions/dependency-cooldown/dist/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9347,8 +9347,9 @@ function parseTimestamp(raw, subject) {
var cargoRegistry = {
id: "cargo",
displayName: "crates.io",
// crates.io asks API clients to stay near one request per second.
// crates.io data-access policy: at most one API request per second.
maxConcurrency: 1,
minRequestIntervalMs: 1e3,
versionUrl(name, version) {
return `https://crates.io/crates/${name}/${version}`;
},
Expand Down Expand Up @@ -9376,6 +9377,7 @@ var npmRegistry = {
id: "npm",
displayName: "npmjs.com",
maxConcurrency: 8,
minRequestIntervalMs: 0,
versionUrl(name, version) {
return `https://www.npmjs.com/package/${name}/v/${version}`;
},
Expand All @@ -9395,6 +9397,7 @@ var pubRegistry = {
id: "pub",
displayName: "pub.dev",
maxConcurrency: 8,
minRequestIntervalMs: 0,
versionUrl(name, version) {
return `https://pub.dev/packages/${name}/versions/${version}`;
},
Expand Down Expand Up @@ -9513,6 +9516,29 @@ function renderSummary(input) {
}

// src/resolve.ts
var systemClock = {
now: () => Date.now(),
sleep: (ms) => new Promise((resolve4) => setTimeout(resolve4, ms))
};
function createStartGate(minIntervalMs, clock) {
if (!Number.isFinite(minIntervalMs) || minIntervalMs < 0) {
throw new Error(
`minRequestIntervalMs must be a non-negative number, got ${String(minIntervalMs)}.`
);
}
let nextAllowedAt = 0;
return async () => {
if (minIntervalMs === 0) {
return;
}
const t = clock.now();
const wait = nextAllowedAt - t;
nextAllowedAt = Math.max(nextAllowedAt, t) + minIntervalMs;
if (wait > 0) {
await clock.sleep(wait);
}
};
}
async function mapWithConcurrency(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
Expand All @@ -9525,7 +9551,7 @@ async function mapWithConcurrency(items, limit, worker) {
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run3));
return results;
}
async function resolvePublishDates(dependencies, http) {
async function resolvePublishDates(dependencies, http, clock = systemClock) {
const byRegistry = /* @__PURE__ */ new Map();
for (const dependency of dependencies) {
const bucket = byRegistry.get(dependency.registry);
Expand All @@ -9538,10 +9564,14 @@ async function resolvePublishDates(dependencies, http) {
const perRegistry = await Promise.all(
[...byRegistry.entries()].map(async ([registryId, bucket]) => {
const registry = REGISTRIES[registryId];
return mapWithConcurrency(bucket, registry.maxConcurrency, async (dependency) => ({
dependency,
publishedAt: await registry.fetchPublishedAt(dependency.name, dependency.version, http)
}));
const waitForSlot = createStartGate(registry.minRequestIntervalMs, clock);
return mapWithConcurrency(bucket, registry.maxConcurrency, async (dependency) => {
await waitForSlot();
return {
dependency,
publishedAt: await registry.fetchPublishedAt(dependency.name, dependency.version, http)
};
});
})
);
return perRegistry.flat();
Expand Down Expand Up @@ -12328,7 +12358,7 @@ async function run2(argv, overrides = {}) {
console.log(
mode === "check" ? `${subjects.length} newly introduced dependency version(s) to verify.` : `${subjects.length} locked dependency version(s) to verify.`
);
const dated = await resolvePublishDates(subjects, http);
const dated = await resolvePublishDates(subjects, http, overrides.clock);
const { violations } = evaluatePolicy(dated, {
minReleaseAgeDays: config.minReleaseAgeDays,
now
Expand Down
5 changes: 3 additions & 2 deletions docs/dependency-cooldown.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,9 @@ The design goal is that a new language costs one file plus one fixture.
silently: put it in `uncheckable` with a reason.
2. **Add a registry client** in `src/registries/<name>.ts` if the ecosystem uses
a registry that is not already supported, exporting a `Registry` that maps a
name and version to a publish date. Set `maxConcurrency` to whatever the
registry's rate limit tolerates. Register it in `src/registries/index.ts` and
name and version to a publish date. Set `maxConcurrency` and
`minRequestIntervalMs` to whatever the registry's rate limit tolerates
(crates.io: one request per second). Register it in `src/registries/index.ts` and
add the id to `RegistryId` in `src/types.ts`.
3. **Register the format** in the `LOCKFILE_FORMATS` array in
`src/lockfiles/index.ts`. Lockfile discovery, diffing, reporting and the
Expand Down
3 changes: 2 additions & 1 deletion packages/dependency-cooldown/src/registries/cargo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { parseTimestamp } from "./timestamp.js";
export const cargoRegistry: Registry = {
id: "cargo",
displayName: "crates.io",
// crates.io asks API clients to stay near one request per second.
// crates.io data-access policy: at most one API request per second.
maxConcurrency: 1,
minRequestIntervalMs: 1000,

versionUrl(name, version) {
return `https://crates.io/crates/${name}/${version}`;
Expand Down
1 change: 1 addition & 0 deletions packages/dependency-cooldown/src/registries/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const npmRegistry: Registry = {
id: "npm",
displayName: "npmjs.com",
maxConcurrency: 8,
minRequestIntervalMs: 0,

versionUrl(name, version) {
return `https://www.npmjs.com/package/${name}/v/${version}`;
Expand Down
1 change: 1 addition & 0 deletions packages/dependency-cooldown/src/registries/pub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const pubRegistry: Registry = {
id: "pub",
displayName: "pub.dev",
maxConcurrency: 8,
minRequestIntervalMs: 0,

versionUrl(name, version) {
return `https://pub.dev/packages/${name}/versions/${version}`;
Expand Down
48 changes: 44 additions & 4 deletions packages/dependency-cooldown/src/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@ import type { HttpClient } from "./registries/http.js";
import type { DatedDependency } from "./policy.js";
import type { LockedDependency } from "./types.js";

export interface Clock {
now(): number;
sleep(ms: number): Promise<void>;
}

const systemClock: Clock = {
now: () => Date.now(),
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
};

/**
* Returns a gate that spaces invocations so a new one cannot start until
* `minIntervalMs` has passed since the previous invocation was admitted.
* Reservations are taken before sleeping so concurrent waiters do not share a slot.
*/
function createStartGate(minIntervalMs: number, clock: Clock): () => Promise<void> {
if (!Number.isFinite(minIntervalMs) || minIntervalMs < 0) {
throw new Error(
`minRequestIntervalMs must be a non-negative number, got ${String(minIntervalMs)}.`,
);
}
let nextAllowedAt = 0;
return async () => {
if (minIntervalMs === 0) {
return;
}
const t = clock.now();
const wait = nextAllowedAt - t;
nextAllowedAt = Math.max(nextAllowedAt, t) + minIntervalMs;
if (wait > 0) {
await clock.sleep(wait);
}
};
}

async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
Expand All @@ -29,6 +64,7 @@ async function mapWithConcurrency<T, R>(
export async function resolvePublishDates(
dependencies: readonly LockedDependency[],
http: HttpClient,
clock: Clock = systemClock,
): Promise<DatedDependency[]> {
const byRegistry = new Map<string, LockedDependency[]>();
for (const dependency of dependencies) {
Expand All @@ -43,10 +79,14 @@ export async function resolvePublishDates(
const perRegistry = await Promise.all(
[...byRegistry.entries()].map(async ([registryId, bucket]) => {
const registry = REGISTRIES[registryId as keyof typeof REGISTRIES];
return mapWithConcurrency(bucket, registry.maxConcurrency, async (dependency) => ({
dependency,
publishedAt: await registry.fetchPublishedAt(dependency.name, dependency.version, http),
}));
const waitForSlot = createStartGate(registry.minRequestIntervalMs, clock);
return mapWithConcurrency(bucket, registry.maxConcurrency, async (dependency) => {
await waitForSlot();
return {
dependency,
publishedAt: await registry.fetchPublishedAt(dependency.name, dependency.version, http),
};
});
}),
);

Expand Down
6 changes: 4 additions & 2 deletions packages/dependency-cooldown/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { annotate, readPullRequestContext, setOutput, writeStepSummary } from ".
import { ORG_MIN_RELEASE_AGE_DAYS, evaluatePolicy } from "./policy.js";
import { type HttpClient, createHttpClient } from "./registries/http.js";
import { renderSummary, violationMessage } from "./report.js";
import { resolvePublishDates } from "./resolve.js";
import { type Clock, resolvePublishDates } from "./resolve.js";
import { discoverLockfiles, listLockfilesAtRef, scanRef, scanWorkingTree } from "./scan.js";

export const DEFAULT_BYPASS_LABEL = "dependency-cooldown-bypass";
Expand Down Expand Up @@ -74,6 +74,8 @@ export interface RunOverrides {
/** Injected by tests so no registry is contacted. */
http?: HttpClient;
now?: Date;
/** Injected by tests so crates.io pacing does not sleep in wall-clock time. */
clock?: Clock;
}

export async function run(argv: string[], overrides: RunOverrides = {}): Promise<number> {
Expand Down Expand Up @@ -120,7 +122,7 @@ export async function run(argv: string[], overrides: RunOverrides = {}): Promise
: `${subjects.length} locked dependency version(s) to verify.`,
);

const dated = await resolvePublishDates(subjects, http);
const dated = await resolvePublishDates(subjects, http, overrides.clock);
const { violations } = evaluatePolicy(dated, {
minReleaseAgeDays: config.minReleaseAgeDays,
now,
Expand Down
9 changes: 7 additions & 2 deletions packages/dependency-cooldown/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,15 @@ export interface Registry {
id: RegistryId;
displayName: string;
/**
* Parallel requests allowed against this registry. crates.io asks for roughly
* one request per second, so it is deliberately serialised.
* Parallel in-flight requests allowed against this registry.
*/
maxConcurrency: number;
/**
* Minimum milliseconds between starting requests to this registry.
* crates.io allows at most one API request per second, so cargo uses 1000.
* Registries with no documented request-rate cap use 0.
*/
minRequestIntervalMs: number;
/** Human-facing page for a version, used in the report. */
versionUrl(name: string, version: string): string;
fetchPublishedAt(name: string, version: string, http: HttpClient): Promise<Date>;
Expand Down
Loading
Loading