Skip to content

Commit 3b4e8db

Browse files
AmeanAsadDiego Rodríguez Baqueroguanzo
authored
feat: Race top n nodes when making requests (#25)
* feat: implement a nodes list for clients * feat: implement a storage interface using indexedDb * feat: implement a test suite for fallback * fix: remove unused code * fix: eslint an jsdoc * fix: formatting and consistency * fix: indexDbCheck * chore: change storage implementation * enhancement: simplify node loading * naive fallback implementation * modify fallback * fix formatting and typos * typos * Update .eslintrc Co-authored-by: Diego Rodríguez Baquero <[email protected]> * enhancement: edit storage impl * enhancement: deal with overlapping byte chunks * feat: add fallback test suite * fix: tests running * cleanup content fetch with fallback * add initial origin fetch to fallback * formatting and file re-org * feat: merge main into fallback branch (#22) * Abort on error (#19) * feat: use controller from options if exists. abort fetch if error occurs. * test: check if external abort controller is used * build: move build output to dist/ folder * fix: newline * 0.1.1 * Build exports (#20) * chore: rename file * feat: add new entrypoint with exports. Switch Saturn to named export * build: expose entire module instead of just the default export * docs: update README * 0.2.0 * feat: include worker scopes when checking for browser runtime (#21) * 0.3.0 --------- Co-authored-by: Eric Guan <[email protected]> * load nodes on first success * add fallback limit * fix: fallback bug * put eslint settings in package.json * add nodesListKey as static * fix: resolve process in browser * feat: add fetching with a race * enhancement: add backward compatibility for racing * tests and cleanup * fixes and enhancements * add typings * add typings --------- Co-authored-by: Diego Rodríguez Baquero <[email protected]> Co-authored-by: Eric Guan <[email protected]>
1 parent 5ef32b0 commit 3b4e8db

File tree

4 files changed

+235
-23
lines changed

4 files changed

+235
-23
lines changed

src/client.js

Lines changed: 140 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ import { parseUrl, addHttpPrefix } from './utils/url.js'
1111
import { isBrowserContext } from './utils/runtime.js'
1212

1313
const MAX_NODE_WEIGHT = 100
14+
/**
15+
* @typedef {import('./types.js').Node} Node
16+
*/
1417

1518
export class Saturn {
1619
static nodesListKey = 'saturn-nodes'
20+
static defaultRaceCount = 3
1721
/**
1822
*
1923
* @param {object} [opts={}]
@@ -53,6 +57,93 @@ export class Saturn {
5357
this.loadNodesPromise = this._loadNodes(this.opts)
5458
}
5559

60+
/**
61+
*
62+
* @param {string} cidPath
63+
* @param {object} [opts={}]
64+
* @param {('car'|'raw')} [opts.format]
65+
* @param {number} [opts.connectTimeout=5000]
66+
* @param {number} [opts.downloadTimeout=0]
67+
* @returns {Promise<object>}
68+
*/
69+
async fetchCIDWithRace (cidPath, opts = {}) {
70+
const [cid] = (cidPath ?? '').split('/')
71+
CID.parse(cid)
72+
73+
const jwt = await getJWT(this.opts, this.storage)
74+
75+
const options = Object.assign({}, this.opts, { format: 'car', jwt }, opts)
76+
77+
if (!isBrowserContext) {
78+
options.headers = {
79+
...(options.headers || {}),
80+
Authorization: 'Bearer ' + options.jwt
81+
}
82+
}
83+
84+
const origins = options.origins
85+
const controllers = []
86+
87+
const createFetchPromise = async (origin) => {
88+
const fetchOptions = { ...options, url: origin }
89+
const url = this.createRequestURL(cidPath, fetchOptions)
90+
91+
const controller = new AbortController()
92+
controllers.push(controller)
93+
const connectTimeout = setTimeout(() => {
94+
controller.abort()
95+
}, options.connectTimeout)
96+
97+
try {
98+
res = await fetch(parseUrl(url), { signal: controller.signal, ...options })
99+
clearTimeout(connectTimeout)
100+
return { res, url, controller }
101+
} catch (err) {
102+
throw new Error(
103+
`Non OK response received: ${res.status} ${res.statusText}`
104+
)
105+
}
106+
}
107+
108+
const abortRemainingFetches = async (successController, controllers) => {
109+
return controllers.forEach((controller) => {
110+
if (successController !== controller) {
111+
controller.abort('Request race unsuccessful')
112+
}
113+
})
114+
}
115+
116+
const fetchPromises = Promise.any(origins.map((origin) => createFetchPromise(origin)))
117+
118+
let log = {
119+
startTime: new Date()
120+
}
121+
122+
let res, url, controller
123+
try {
124+
({ res, url, controller } = await fetchPromises)
125+
126+
abortRemainingFetches(controller, controllers)
127+
log = Object.assign(log, this._generateLog(res, log), { url })
128+
129+
if (!res.ok) {
130+
throw new Error(
131+
`Non OK response received: ${res.status} ${res.statusText}`
132+
)
133+
}
134+
} catch (err) {
135+
if (!res) {
136+
log.error = err.message
137+
}
138+
// Report now if error, otherwise report after download is done.
139+
this._finalizeLog(log)
140+
141+
throw err
142+
}
143+
144+
return { res, controller, log }
145+
}
146+
56147
/**
57148
*
58149
* @param {string} cidPath
@@ -70,8 +161,7 @@ export class Saturn {
70161

71162
const options = Object.assign({}, this.opts, { format: 'car', jwt }, opts)
72163
const url = this.createRequestURL(cidPath, options)
73-
74-
const log = {
164+
let log = {
75165
url,
76166
startTime: new Date()
77167
}
@@ -93,13 +183,7 @@ export class Saturn {
93183

94184
clearTimeout(connectTimeout)
95185

96-
const { headers } = res
97-
log.ttfbMs = new Date() - log.startTime
98-
log.httpStatusCode = res.status
99-
log.cacheHit = headers.get('saturn-cache-status') === 'HIT'
100-
log.nodeId = headers.get('saturn-node-id')
101-
log.requestId = headers.get('saturn-transfer-id')
102-
log.httpProtocol = headers.get('quic-status')
186+
log = Object.assign(log, this._generateLog(res, log))
103187

104188
if (!res.ok) {
105189
throw new Error(
@@ -119,11 +203,32 @@ export class Saturn {
119203
return { res, controller, log }
120204
}
121205

206+
/**
207+
* @param {Response} res
208+
* @param {object} log
209+
* @returns {object}
210+
*/
211+
_generateLog (res, log) {
212+
const { headers } = res
213+
log.httpStatusCode = res.status
214+
log.cacheHit = headers.get('saturn-cache-status') === 'HIT'
215+
log.nodeId = headers.get('saturn-node-id')
216+
log.requestId = headers.get('saturn-transfer-id')
217+
log.httpProtocol = headers.get('quic-status')
218+
219+
if (res.ok) {
220+
log.ttfbMs = new Date() - log.startTime
221+
}
222+
223+
return log
224+
}
225+
122226
/**
123227
*
124228
* @param {string} cidPath
125229
* @param {object} [opts={}]
126230
* @param {('car'|'raw')} [opts.format]
231+
* @param {boolean} [opts.raceNodes]
127232
* @param {string} [opts.url]
128233
* @param {number} [opts.connectTimeout=5000]
129234
* @param {number} [opts.downloadTimeout=0]
@@ -168,11 +273,18 @@ export class Saturn {
168273
}
169274

170275
let fallbackCount = 0
171-
for (const origin of this.nodes) {
276+
const nodes = this.nodes
277+
for (let i = 0; i < nodes.length; i++) {
172278
if (fallbackCount > this.opts.fallbackLimit) {
173279
return
174280
}
175-
opts.url = origin.url
281+
if (opts.raceNodes) {
282+
const origins = nodes.slice(i, i + Saturn.defaultRaceCount).map((node) => node.url)
283+
opts.origins = origins
284+
} else {
285+
opts.url = nodes[i].url
286+
}
287+
176288
try {
177289
yield * fetchContent()
178290
return
@@ -191,13 +303,20 @@ export class Saturn {
191303
*
192304
* @param {string} cidPath
193305
* @param {object} [opts={}]
194-
* @param {('car'|'raw')} [opts.format]
306+
* @param {('car'|'raw')} [opts.format]- -
307+
* @param {boolean} [opts.raceNodes]- -
195308
* @param {number} [opts.connectTimeout=5000]
196309
* @param {number} [opts.downloadTimeout=0]
197310
* @returns {Promise<AsyncIterable<Uint8Array>>}
198311
*/
199312
async * fetchContent (cidPath, opts = {}) {
200-
const { res, controller, log } = await this.fetchCID(cidPath, opts)
313+
let res, controller, log
314+
315+
if (opts.raceNodes) {
316+
({ res, controller, log } = await this.fetchCIDWithRace(cidPath, opts))
317+
} else {
318+
({ res, controller, log } = await this.fetchCID(cidPath, opts))
319+
}
201320

202321
async function * metricsIterable (itr) {
203322
log.numBytesSent = 0
@@ -226,6 +345,7 @@ export class Saturn {
226345
* @param {string} cidPath
227346
* @param {object} [opts={}]
228347
* @param {('car'|'raw')} [opts.format]
348+
* @param {boolean} [opts.raceNodes]
229349
* @param {number} [opts.connectTimeout=5000]
230350
* @param {number} [opts.downloadTimeout=0]
231351
* @returns {Promise<Uint8Array>}
@@ -241,7 +361,7 @@ export class Saturn {
241361
* @returns {URL}
242362
*/
243363
createRequestURL (cidPath, opts) {
244-
let origin = opts.url || opts.cdnURL
364+
let origin = opts.url || (opts.origins && opts.origins[0]) || opts.cdnURL
245365
origin = addHttpPrefix(origin)
246366
const url = new URL(`${origin}/ipfs/${cidPath}`)
247367

@@ -371,6 +491,12 @@ export class Saturn {
371491
}
372492
}
373493

494+
/**
495+
* Sorts nodes based on normalized distance and weights. Distance is prioritized for sorting.
496+
*
497+
* @param {Node[]} nodes
498+
* @returns {Node[]}
499+
*/
374500
_sortNodes (nodes) {
375501
// Determine the maximum distance for normalization
376502
const maxDistance = Math.max(...nodes.map(node => node.distance))

src/types.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
2+
/**
3+
* @module types */
4+
5+
/**
6+
*
7+
* @typedef {object} Node
8+
* @property {string} ip
9+
* @property {number} weight
10+
* @property {number} distance
11+
* @property {string} url
12+
*/
13+
14+
export {}

test/fallback.spec.js

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,74 @@ describe('Client Fallback', () => {
128128

129129
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY, clientId: 'test' })
130130

131-
const cid = saturn.fetchContentWithFallback('bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4', { url: 'node1.saturn.ms' })
131+
const cid = saturn.fetchContentWithFallback('bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4')
132+
133+
const buffer = await concatChunks(cid)
134+
const actualContent = String.fromCharCode(...buffer)
135+
const expectedContent = 'hello world\n'
136+
137+
assert.strictEqual(actualContent, expectedContent)
138+
server.close()
139+
mock.reset()
140+
})
141+
142+
test('Content Fallback fetches a cid properly with race', async (t) => {
143+
const handlers = [
144+
mockOrchHandler(5, TEST_DEFAULT_ORCH, 'saturn.ms'),
145+
mockJWT(TEST_AUTH),
146+
mockSaturnOriginHandler(TEST_ORIGIN_DOMAIN, 0, true),
147+
...mockNodesHandlers(5, TEST_ORIGIN_DOMAIN)
148+
]
149+
const server = getMockServer(handlers)
150+
server.listen(MSW_SERVER_OPTS)
151+
152+
const expectedNodes = generateNodes(3, TEST_ORIGIN_DOMAIN)
153+
154+
// Mocking storage object
155+
const mockStorage = {
156+
get: async (key) => expectedNodes,
157+
set: async (key, value) => { return null }
158+
}
159+
t.mock.method(mockStorage, 'get')
160+
t.mock.method(mockStorage, 'set')
161+
162+
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY, clientId: 'test' })
163+
// const origins =
164+
165+
const cid = saturn.fetchContentWithFallback('bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4', { raceNodes: true })
166+
167+
const buffer = await concatChunks(cid)
168+
const actualContent = String.fromCharCode(...buffer)
169+
const expectedContent = 'hello world\n'
170+
171+
assert.strictEqual(actualContent, expectedContent)
172+
server.close()
173+
mock.reset()
174+
})
175+
176+
test('Content Fallback with race fetches from consecutive nodes on failure', async (t) => {
177+
const handlers = [
178+
mockOrchHandler(5, TEST_DEFAULT_ORCH, 'saturn.ms'),
179+
mockJWT(TEST_AUTH),
180+
mockSaturnOriginHandler(TEST_ORIGIN_DOMAIN, 0, true),
181+
...mockNodesHandlers(5, TEST_ORIGIN_DOMAIN, 2)
182+
]
183+
const server = getMockServer(handlers)
184+
server.listen(MSW_SERVER_OPTS)
185+
186+
const expectedNodes = generateNodes(5, TEST_ORIGIN_DOMAIN)
187+
188+
// Mocking storage object
189+
const mockStorage = {
190+
get: async (key) => expectedNodes,
191+
set: async (key, value) => { return null }
192+
}
193+
t.mock.method(mockStorage, 'get')
194+
t.mock.method(mockStorage, 'set')
195+
196+
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY, clientId: 'test' })
197+
198+
const cid = saturn.fetchContentWithFallback('bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4', { raceNodes: true })
132199

133200
const buffer = await concatChunks(cid)
134201
const actualContent = String.fromCharCode(...buffer)

test/test-utils.js

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,13 @@ import fs from 'fs'
1010
import { addHttpPrefix } from '../src/utils/url.js'
1111

1212
const HTTP_STATUS_OK = 200
13+
const HTTP_STATUS_TIMEOUT = 504
1314

1415
const __dirname = dirname(fileURLToPath(import.meta.url))
1516
process.env.TESTING = 'true'
1617

1718
/**
18-
*
19-
* @typedef {object} Node
20-
* @property {string} ip
21-
* @property {number} weight
22-
* @property {number} distance
23-
* @property {string} url
19+
* @typedef {import('../src/types.js').Node} Node
2420
*/
2521

2622
/**
@@ -123,14 +119,23 @@ export function mockJWT (authURL) {
123119
*
124120
* @param {number} count - amount of nodes to mock
125121
* @param {string} originDomain - saturn origin domain.
122+
* @param {number} failures
126123
* @returns {RestHandler<any>[]}
127124
*/
128-
export function mockNodesHandlers (count, originDomain) {
125+
export function mockNodesHandlers (count, originDomain, failures = 0) {
126+
if (failures > count) {
127+
throw Error('failures number cannot exceed node count')
128+
}
129129
const nodes = generateNodes(count, originDomain)
130130

131-
const handlers = nodes.map((node) => {
131+
const handlers = nodes.map((node, idx) => {
132132
const url = `${node.url}/ipfs/:cid`
133133
return rest.get(url, (req, res, ctx) => {
134+
if (idx < failures) {
135+
return res(
136+
ctx.status(HTTP_STATUS_TIMEOUT)
137+
)
138+
}
134139
const filepath = getFixturePath('hello.car')
135140
const fileContents = fs.readFileSync(filepath)
136141
return res(

0 commit comments

Comments
 (0)