-
Notifications
You must be signed in to change notification settings - Fork 4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Race top n nodes when making requests #25
Changes from 35 commits
804978c
f4d1844
894d16d
7009c7a
3336a87
696cd8a
a3dec56
747d780
8dec1c7
b0dc673
78651fc
d15c0ad
29e8da1
194ce70
1d97dc9
84a1b8e
f688406
5b7c215
6068a90
2c3cc79
4cf0c00
d81038f
a1ecd50
55d56d1
d2c5c37
e0065d8
109019b
b84ce4a
7731fe8
e0fcfd8
ac1b9f8
489842a
9de35fc
c06a2b6
ceda4c2
7249aea
57697d2
c0e3e93
7d0b34e
4ef930c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,6 +12,7 @@ import { isBrowserContext } from './utils/runtime.js' | |
|
||
export class Saturn { | ||
static nodesListKey = 'saturn-nodes' | ||
static defaultRaceCount = 3 | ||
/** | ||
* | ||
* @param {object} [opts={}] | ||
|
@@ -51,6 +52,101 @@ export class Saturn { | |
this.loadNodesPromise = this._loadNodes(this.opts) | ||
} | ||
|
||
/** | ||
* | ||
* @param {string} cidPath | ||
* @param {object} [opts={}] | ||
* @param {('car'|'raw')} [opts.format] | ||
* @param {number} [opts.connectTimeout=5000] | ||
* @param {number} [opts.downloadTimeout=0] | ||
* @returns {Promise<object>} | ||
*/ | ||
async fetchCIDWithRace (cidPath, opts = {}) { | ||
const [cid] = (cidPath ?? '').split('/') | ||
CID.parse(cid) | ||
|
||
const jwt = await getJWT(this.opts, this.storage) | ||
|
||
const options = Object.assign({}, this.opts, { format: 'car', jwt }, opts) | ||
|
||
if (!isBrowserContext) { | ||
options.headers = { | ||
...(options.headers || {}), | ||
Authorization: 'Bearer ' + options.jwt | ||
} | ||
} | ||
|
||
const origins = options.origins | ||
const controllers = [] | ||
|
||
const createFetchPromise = async (origin) => { | ||
options.url = origin | ||
const url = this.createRequestURL(cidPath, options) | ||
|
||
const controller = new AbortController() | ||
controllers.push(controller) | ||
const connectTimeout = setTimeout(() => { | ||
controller.abort() | ||
}, options.connectTimeout) | ||
|
||
try { | ||
res = await fetch(parseUrl(url), { signal: controller.signal, ...options }) | ||
clearTimeout(connectTimeout) | ||
return { res, url, controller } | ||
} catch (err) { | ||
throw new Error( | ||
`Non OK response received: ${res.status} ${res.statusText}` | ||
) | ||
} | ||
} | ||
|
||
const abortRemainingFetches = async (res, controllers) => { | ||
return controllers.forEach((controller) => { | ||
if (res.controller !== controller) { | ||
AmeanAsad marked this conversation as resolved.
Show resolved
Hide resolved
|
||
controller.abort('Request race unsuccessful') | ||
} | ||
}) | ||
} | ||
|
||
const fetchPromises = Promise.any(origins.map((origin) => createFetchPromise(origin))) | ||
|
||
const log = { | ||
startTime: new Date() | ||
} | ||
|
||
let res, url, controller | ||
try { | ||
({ res, url, controller } = await fetchPromises) | ||
|
||
abortRemainingFetches(res, controllers) | ||
|
||
const { headers } = res | ||
log.url = url | ||
log.ttfbMs = new Date() - log.startTime | ||
guanzo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
log.httpStatusCode = res.status | ||
log.cacheHit = headers.get('saturn-cache-status') === 'HIT' | ||
log.nodeId = headers.get('saturn-node-id') | ||
log.requestId = headers.get('saturn-transfer-id') | ||
log.httpProtocol = headers.get('quic-status') | ||
AmeanAsad marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if (!res.ok) { | ||
throw new Error( | ||
`Non OK response received: ${res.status} ${res.statusText}` | ||
) | ||
} | ||
} catch (err) { | ||
if (!res) { | ||
log.error = err.message | ||
} | ||
// Report now if error, otherwise report after download is done. | ||
this._finalizeLog(log) | ||
|
||
throw err | ||
} | ||
|
||
return { res, controller, log } | ||
} | ||
|
||
/** | ||
* | ||
* @param {string} cidPath | ||
|
@@ -68,7 +164,6 @@ export class Saturn { | |
|
||
const options = Object.assign({}, this.opts, { format: 'car', jwt }, opts) | ||
const url = this.createRequestURL(cidPath, options) | ||
|
||
const log = { | ||
url, | ||
startTime: new Date() | ||
|
@@ -122,6 +217,7 @@ export class Saturn { | |
* @param {string} cidPath | ||
* @param {object} [opts={}] | ||
* @param {('car'|'raw')} [opts.format] | ||
* @param {boolean} [opts.raceNodes] | ||
* @param {string} [opts.url] | ||
* @param {number} [opts.connectTimeout=5000] | ||
* @param {number} [opts.downloadTimeout=0] | ||
|
@@ -166,11 +262,18 @@ export class Saturn { | |
} | ||
|
||
let fallbackCount = 0 | ||
for (const origin of this.nodes) { | ||
const nodes = this.nodes | ||
for (let i = 0; i < nodes.length; i++) { | ||
if (fallbackCount > this.opts.fallbackLimit) { | ||
return | ||
} | ||
opts.url = origin.url | ||
if (opts.raceNodes) { | ||
const origins = nodes.slice(i, i + Saturn.defaultRaceCount).map((node) => node.url) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So if we have nodes a,b,c,d,e, then the race groups are:
Is that right? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That is correct. Will also note that this is not necessarily the best way to do it and has some obvious room for improvement. For example:
I think as a next step, the best way to do this is to remove nodes that fail and such that:
I think this can be better achieved with a hashring implementation but we can re-evaluate. |
||
opts.origins = origins | ||
} else { | ||
opts.url = nodes[i].url | ||
} | ||
|
||
try { | ||
yield * fetchContent() | ||
return | ||
|
@@ -189,13 +292,20 @@ export class Saturn { | |
* | ||
* @param {string} cidPath | ||
* @param {object} [opts={}] | ||
* @param {('car'|'raw')} [opts.format] | ||
* @param {('car'|'raw')} [opts.format]- - | ||
* @param {boolean} [opts.raceNodes]- - | ||
* @param {number} [opts.connectTimeout=5000] | ||
* @param {number} [opts.downloadTimeout=0] | ||
* @returns {Promise<AsyncIterable<Uint8Array>>} | ||
*/ | ||
async * fetchContent (cidPath, opts = {}) { | ||
const { res, controller, log } = await this.fetchCID(cidPath, opts) | ||
let res, controller, log | ||
|
||
if (opts.raceNodes) { | ||
({ res, controller, log } = await this.fetchCIDWithRace(cidPath, opts)) | ||
} else { | ||
({ res, controller, log } = await this.fetchCID(cidPath, opts)) | ||
} | ||
|
||
async function * metricsIterable (itr) { | ||
log.numBytesSent = 0 | ||
|
@@ -224,6 +334,7 @@ export class Saturn { | |
* @param {string} cidPath | ||
* @param {object} [opts={}] | ||
* @param {('car'|'raw')} [opts.format] | ||
* @param {boolean} [opts.raceNodes] | ||
* @param {number} [opts.connectTimeout=5000] | ||
* @param {number} [opts.downloadTimeout=0] | ||
* @returns {Promise<Uint8Array>} | ||
|
@@ -239,7 +350,7 @@ export class Saturn { | |
* @returns {URL} | ||
*/ | ||
createRequestURL (cidPath, opts) { | ||
let origin = opts.url || opts.cdnURL | ||
let origin = opts.url || (opts.origins && opts.origins[0]) || opts.cdnURL | ||
origin = addHttpPrefix(origin) | ||
const url = new URL(`${origin}/ipfs/${cidPath}`) | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Better for each function call to make a copy of the
options
object instead of sharing it and mutating fields.This is only a shallow copy but should suffice.