@lde/distribution-probe 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -56
- package/dist/probe.d.ts.map +1 -1
- package/dist/probe.js +5 -67
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lde/distribution-probe
|
|
2
2
|
|
|
3
3
|
Probes a DCAT `Distribution` to check availability and gather metadata. Returns `SparqlProbeResult`, `DataDumpProbeResult`, or `NetworkError` – the probe never throws.
|
|
4
4
|
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @lde/distribution-probe
|
|
9
|
+
```
|
|
10
|
+
|
|
5
11
|
```ts
|
|
6
12
|
import { Distribution } from '@lde/dataset';
|
|
7
13
|
import { probe } from '@lde/distribution-probe';
|
|
@@ -13,59 +19,6 @@ const distribution = new Distribution(
|
|
|
13
19
|
const result = await probe(distribution);
|
|
14
20
|
```
|
|
15
21
|
|
|
16
|
-
##
|
|
17
|
-
|
|
18
|
-
### SPARQL endpoints
|
|
19
|
-
|
|
20
|
-
Sends `POST` with the configured query (default `SELECT * { ?s ?p ?o } LIMIT 1`). The query type is detected (`ASK` / `SELECT` / `CONSTRUCT` / `DESCRIBE`) and drives both the `Accept` header and how the response is validated:
|
|
21
|
-
|
|
22
|
-
- **`ASK` / `SELECT`** request `application/sparql-results+json`, with `application/sparql-results+xml` as a lower-priority fallback. The response Content-Type must be one of those — anything else fails the probe (`isSuccess() === false`), which rules out HTML error pages served with `200 OK`. The body must parse and contain a results document (a `results` object for `SELECT`, a `boolean` for `ASK`); empty bodies, invalid JSON/XML, and missing results all fail with a `failureReason`.
|
|
23
|
-
- **`CONSTRUCT` / `DESCRIBE`** request the common RDF serializations (`text/turtle`, `application/n-triples`, `application/rdf+xml`, `application/ld+json`, `application/n-quads`, `application/trig`) and accept any of them. A `2xx` RDF response confirms availability, and **an empty graph is a valid answer** — so an empty body does not fail the probe (unlike a data dump, which must be non-empty). The body is not parse-validated.
|
|
24
|
-
|
|
25
|
-
### Data dumps
|
|
26
|
-
|
|
27
|
-
#### Reachability (the default)
|
|
28
|
-
|
|
29
|
-
Sends `HEAD` with `Accept: <distribution.mimeType>` and `Accept-Encoding: identity`. A successful `HEAD` settles reachability and gathers metadata (`Content-Length`, `Last-Modified`) **without reading the body**. If `HEAD` is unsuccessful — e.g. a server that returns `405`/`501` because it does not implement `HEAD` — the probe falls back to a body-less `GET` to confirm the endpoint is up. The body is never downloaded.
|
|
30
|
-
|
|
31
|
-
This is deliberately cheap: reading a body forces a slow, generate-on-the-fly endpoint (a TriplyDB dump, a SPARQL `CONSTRUCT` export) to start producing its export, which a `HEAD` does not.
|
|
32
|
-
|
|
33
|
-
- **Content-Type is checked as a soft warning, not a hard failure.** If the server’s Content-Type disagrees with the distribution’s declared `mimeType`, a message is appended to `result.warnings` but `isSuccess()` stays `true`. Compression wrappers (`application/gzip`, `application/x-gzip`, `application/octet-stream`) are skipped so a gzipped Turtle file doesn’t trigger a warning.
|
|
34
|
-
|
|
35
|
-
#### Content validation (opt-in)
|
|
36
|
-
|
|
37
|
-
Set `validateRdfContent: true` to additionally confirm that a dump actually carries RDF. It applies only to distributions whose **declared** `mimeType` is an RDF serialization (`text/turtle`, `application/n-triples`, `application/n-quads`, `application/trig`, `text/n3`, `application/ld+json`, `application/rdf+xml`); non-RDF and undeclared-type distributions stay reachability-only.
|
|
38
|
-
|
|
39
|
-
When on, the probe `GET`s the dump — **regardless of size** — and reads only a **bounded prefix** (256 KiB), never the whole body:
|
|
40
|
-
|
|
41
|
-
- It settles on the **first triple** and stops, so a large dump is validated from its opening chunk. The line/statement-oriented serializations and RDF/XML stream a triple out of the prefix; **JSON-LD is not streamable** (its parser needs the whole document), so a JSON-LD dump is only validated when it fits the prefix in full — a larger one is reported reachable but unvalidated.
|
|
42
|
-
- A gzip body that `fetch` did not decompress (a `.gz` dump, or one served with a non-standard `Content-Encoding`) is inflated in-place; a gzip that will not inflate when the **complete** compressed body was read fails as `Distribution is not valid gzip`.
|
|
43
|
-
- Empty bodies (`Distribution is empty`) and bodies that parse to **zero** triples (`Distribution contains no RDF triples`) fail the probe. A deliberately truncated prefix is never mistaken for either — it is inconclusive.
|
|
44
|
-
- **Reachability is settled by the response, so validation never turns a reachable dump into a failure.** If no triple surfaces within `rdfValidationBudgetMs` (default `min(timeoutMs, 2000)`, clamped to `timeoutMs`), the read is aborted and the distribution is reported reachable but unvalidated (no `failureReason`). This bounds the extra latency content validation adds on slow, generate-on-the-fly endpoints.
|
|
45
|
-
|
|
46
|
-
### Network errors
|
|
47
|
-
|
|
48
|
-
A thrown exception from `fetch` (DNS failure, connection refused, socket reset, TLS error, timeout after the configured `timeoutMs` – default 5 000 ms) is a connection-level failure. The probe retries these up to `retries` times (default 2) with a short backoff before giving up and returning a `NetworkError`. This turns a transient transport blip into a reliable single measurement without looking backward across checks. A genuine outage still resolves to a `NetworkError` on the current check – every attempt fails – but note each attempt gets its own `timeoutMs`, so an endpoint that fails only by timing out takes up to `(retries + 1) × timeoutMs` (plus backoff) to be reported down. HTTP error responses (4xx/5xx) and content-validation failures are real ‘down’ states and are **never** retried.
|
|
49
|
-
|
|
50
|
-
`NetworkError.message` includes the underlying `error.cause` (e.g. `ECONNRESET`, `UND_ERR_SOCKET “other side closed”`) when Node wraps one, so observations record what actually failed rather than a bare ‘fetch failed’.
|
|
51
|
-
|
|
52
|
-
## Probing many distributions
|
|
53
|
-
|
|
54
|
-
`probeMany` probes an array of distributions concurrently and returns one result per input, in input order. Each distribution is probed once with `probe`, so every behaviour above applies per distribution; like `probe`, `probeMany` never throws – a probe that fails is reported as a `NetworkError` in its slot.
|
|
55
|
-
|
|
56
|
-
```ts
|
|
57
|
-
import { probeMany } from '@lde/distribution-probe';
|
|
58
|
-
|
|
59
|
-
const results = await probeMany(distributions, {
|
|
60
|
-
concurrency: 20, // max probes in flight across all hosts (default 20)
|
|
61
|
-
perHostConcurrency: 4, // max probes in flight against one host (default 4)
|
|
62
|
-
validateRdfContent: true, // any ProbeOptions are forwarded to each probe
|
|
63
|
-
});
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
Two caps bound the batch:
|
|
67
|
-
|
|
68
|
-
- **`concurrency`** bounds the total fan-out, so a large catalogue does not exhaust sockets or buffer too many response bodies at once.
|
|
69
|
-
- **`perHostConcurrency`** bounds the burst any one server sees, keeping the batch a polite client: a catalogue that declares many distributions on a single host (e.g. a download endpoint per named graph) will not trip that server’s rate limiter (HTTP 429). Distributions sharing a host (by `accessUrl`) contend for the same budget; a probe whose host is saturated waits while probes for other hosts proceed, so one busy host never idles the global pool.
|
|
22
|
+
## Documentation
|
|
70
23
|
|
|
71
|
-
|
|
24
|
+
See the [full documentation](https://ldelements.org/reference/distribution-probe).
|
package/dist/probe.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"probe.d.ts","sourceRoot":"","sources":["../src/probe.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"probe.d.ts","sourceRoot":"","sources":["../src/probe.ts"],"names":[],"mappings":"AAKA,OAAO,EAAyB,YAAY,EAAE,MAAM,cAAc,CAAC;AAKnE;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;;;;;OAUG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;;;;;;;;;OASG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACzD;AAkCD;;GAEG;AACH,qBAAa,YAAY;aAEL,GAAG,EAAE,MAAM;aACX,OAAO,EAAE,MAAM;aACf,cAAc,EAAE,MAAM;gBAFtB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,MAAM;CAEzC;AAED;;GAEG;AACH,uBAAe,WAAW;aAUN,GAAG,EAAE,MAAM;IAT7B,SAAgB,UAAU,EAAE,MAAM,CAAC;IACnC,SAAgB,UAAU,EAAE,MAAM,CAAC;IACnC,SAAgB,YAAY,EAAE,IAAI,GAAG,IAAI,CAAQ;IACjD,SAAgB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,SAAgB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,SAAgB,QAAQ,EAAE,MAAM,EAAE,CAAM;IACxC,SAAgB,cAAc,EAAE,MAAM,CAAC;gBAGrB,GAAG,EAAE,MAAM,EAC3B,QAAQ,EAAE,QAAQ,EAClB,cAAc,EAAE,MAAM,EACtB,aAAa,GAAE,MAAM,GAAG,IAAW;IAa9B,SAAS,IAAI,OAAO;CAO5B;AAqBD;;GAEG;AACH,qBAAa,iBAAkB,SAAQ,WAAW;IAChD;;;;;OAKG;IACH,SAAgB,oBAAoB,EAAE,SAAS,MAAM,EAAE,CAAC;gBAGtD,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,QAAQ,EAClB,cAAc,EAAE,MAAM,EACtB,oBAAoB,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,EAChD,aAAa,GAAE,MAAM,GAAG,IAAW;IAS5B,SAAS,IAAI,OAAO;CAQ9B;AAED;;GAEG;AACH,qBAAa,mBAAoB,SAAQ,WAAW;IAClD,SAAgB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAQ;gBAGhD,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,QAAQ,EAClB,cAAc,EAAE,MAAM,EACtB,aAAa,GAAE,MAAM,GAAG,IAAW;CAQtC;AAED,MAAM,MAAM,eAAe,GACzB,iBAAiB,GAAG,mBAAmB,GAAG,YAAY,CAAC;AAIzD;;;;;;;;;GASG;AACH,wBAAsB,KAAK,CACzB,YAAY,EAAE,YAAY,EAC1B,OAAO,CAAC,EAAE,YAAY,GACrB,OAAO,CAAC,eAAe,CAAC,CAqD1B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,SAAS,CAC7B,aAAa,EAAE,SAAS,YAAY,EAAE,EACtC,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,eAAe,EAAE,CAAC,CAsC5B"}
|
package/dist/probe.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hostKeyOf, mapHostLimited, positiveIntOrDefault, } from '@lde/host-limiter';
|
|
1
2
|
import { compressionMediaTypes } from '@lde/dataset';
|
|
2
3
|
import { rdfParser } from 'rdf-parse';
|
|
3
4
|
import { Readable } from 'node:stream';
|
|
@@ -192,10 +193,10 @@ export async function probeMany(distributions, options) {
|
|
|
192
193
|
// the caller.
|
|
193
194
|
const globalLimit = positiveIntOrDefault(options?.concurrency, DEFAULT_PROBE_CONCURRENCY);
|
|
194
195
|
const perHostLimit = positiveIntOrDefault(options?.perHostConcurrency, DEFAULT_PROBE_PER_HOST_CONCURRENCY);
|
|
195
|
-
// Probes contend per host
|
|
196
|
-
// empty host, so it falls back to its full href and never
|
|
197
|
-
// an unrelated one.
|
|
198
|
-
const hostKeys = distributions.map((distribution) => distribution.accessUrl
|
|
196
|
+
// Probes contend per host, by the shared rule: an authority-less URL (e.g.
|
|
197
|
+
// urn:, file:) has an empty host, so it falls back to its full href and never
|
|
198
|
+
// shares a budget with an unrelated one.
|
|
199
|
+
const hostKeys = distributions.map((distribution) => hostKeyOf(distribution.accessUrl));
|
|
199
200
|
// Report progress as each probe settles. mapHostLimited resolves results in
|
|
200
201
|
// input order, but tasks complete out of order, so count completions here
|
|
201
202
|
// rather than rely on result position. The total is the batch size.
|
|
@@ -209,69 +210,6 @@ export async function probeMany(distributions, options) {
|
|
|
209
210
|
return result;
|
|
210
211
|
});
|
|
211
212
|
}
|
|
212
|
-
/**
|
|
213
|
-
* Coerce an optional concurrency budget to a usable value: a positive integer is
|
|
214
|
-
* taken as-is; undefined, zero, negative, fractional, or NaN falls back to the
|
|
215
|
-
* default. Matches probe()’s treatment of an invalid retries value.
|
|
216
|
-
*/
|
|
217
|
-
function positiveIntOrDefault(value, fallback) {
|
|
218
|
-
return value !== undefined && Number.isInteger(value) && value >= 1
|
|
219
|
-
? value
|
|
220
|
-
: fallback;
|
|
221
|
-
}
|
|
222
|
-
/**
|
|
223
|
-
* Run `task` over `items` with two concurrency caps — a global cap and a per-host
|
|
224
|
-
* cap keyed by `hostKeys[index]` — resolving to results in input order. When the
|
|
225
|
-
* next queued item’s host is at the per-host cap it is skipped for a later item on
|
|
226
|
-
* a different host, so a saturated host never idles the global pool (no head-of-line
|
|
227
|
-
* blocking); the skipped host always has a task in flight, whose completion re-runs
|
|
228
|
-
* the scheduler, so the queue always drains. `task` must not reject — callers wrap
|
|
229
|
-
* failures into a result value — as a rejection would leave the promise pending.
|
|
230
|
-
*/
|
|
231
|
-
function mapHostLimited(items, hostKeys, globalLimit, perHostLimit, task) {
|
|
232
|
-
const results = new Array(items.length);
|
|
233
|
-
const perHostInFlight = new Map();
|
|
234
|
-
const pending = items.map((_unused, index) => index);
|
|
235
|
-
let globalInFlight = 0;
|
|
236
|
-
let settledCount = 0;
|
|
237
|
-
const adjustHost = (host, delta) => {
|
|
238
|
-
perHostInFlight.set(host, (perHostInFlight.get(host) ?? 0) + delta);
|
|
239
|
-
};
|
|
240
|
-
return new Promise((resolve) => {
|
|
241
|
-
const schedule = () => {
|
|
242
|
-
let cursor = 0;
|
|
243
|
-
while (cursor < pending.length && globalInFlight < globalLimit) {
|
|
244
|
-
const index = pending[cursor];
|
|
245
|
-
const host = hostKeys[index];
|
|
246
|
-
if ((perHostInFlight.get(host) ?? 0) >= perHostLimit) {
|
|
247
|
-
cursor++; // Host saturated; leave it queued and try a later, different host.
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
pending.splice(cursor, 1);
|
|
251
|
-
globalInFlight++;
|
|
252
|
-
adjustHost(host, 1);
|
|
253
|
-
void task(items[index]).then((result) => {
|
|
254
|
-
results[index] = result;
|
|
255
|
-
globalInFlight--;
|
|
256
|
-
adjustHost(host, -1);
|
|
257
|
-
settledCount++;
|
|
258
|
-
if (settledCount === items.length) {
|
|
259
|
-
resolve(results);
|
|
260
|
-
}
|
|
261
|
-
else {
|
|
262
|
-
schedule();
|
|
263
|
-
}
|
|
264
|
-
});
|
|
265
|
-
// pending[cursor] now holds the next queued item; do not advance cursor.
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
schedule();
|
|
269
|
-
// Resolve immediately when there is nothing to settle (empty input); a
|
|
270
|
-
// non-empty run resolves via the task completion above.
|
|
271
|
-
if (settledCount === items.length)
|
|
272
|
-
resolve(results);
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
213
|
function delay(milliseconds) {
|
|
276
214
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
277
215
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/distribution-probe",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "git+https://github.com/ldelements/lde.git",
|
|
6
6
|
"directory": "packages/distribution-probe"
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@lde/dataset": "^0.7.9",
|
|
28
|
+
"@lde/host-limiter": "^0.0.1",
|
|
28
29
|
"rdf-parse": "^5.0.0",
|
|
29
30
|
"tslib": "^2.3.0"
|
|
30
31
|
}
|