Skip to content

Commit 10f21e9

Browse files
authored
refactor: on-demand remote pin status checks (#1903)
* refactor: on-demand remote pin status This is bare minimum to restore remote pinning with rate-limited Pinata. Details in #1900 (comment) - The expensive 'ipfs pin remote service ls --stat' and autopin MFS policy is executed only on Settings screen - Files screen does not make any requests to remote pinning service anymore. - Remote pin status check is triggered by opening "Set pinning" modal via context menu or the pin icon, or by clicking on "Pin Status" column header. – We cache aggressively and assume pins on remote service are not changed by other means. The cache lives in memory, reloading the webui will purge cached states. - Clicking on "Pin Status" header bypasses remote pin status cache and forces online status re-check for all items in current directory. - Errors returned by pinning service are now correctly reported to the user. The CLI and ipfs-webui users will now see the same error message. * refactor: IndexedDB persistence of remote pins this leverages createCacheBundle (src/bundles/index.js) and persistActions (src/bundles/pinning.js) for persisting remote pin states across page reloads new users should not see any functional difference, old users may need to manually go to "Set pinning" to trigger status fetch, but that is the best we can do and a fair compromise, given that before this remote pins at Pinata did not work at all due to throttling (#1900) * refactor(i18n): new key to force new translation added two variables, and old key had none. using a different key ensures users use English instead of old translation
1 parent 540d9dd commit 10f21e9

File tree

12 files changed

+237
-195
lines changed

12 files changed

+237
-195
lines changed

package-lock.json

+96-72
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/locales/en/notify.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"ipfsInvalidApiAddress": "The provided IPFS API address is invalid.",
55
"ipfsConnectSuccess": "Successfully connected to the IPFS API address",
66
"ipfsConnectFail": "Unable to connect to the provided IPFS API address",
7-
"ipfsPinFail": "Unable to set pinning. Try again, or see the browser console for more info.",
7+
"ipfsPinFailReason": "Unable to set pinning at {serviceName}: {errorMsg}",
88
"ipfsIsBack": "Normal IPFS service has resumed. Enjoy!",
99
"folderExists": "An item with that name already exists. Please choose another.",
1010
"filesFetchFailed": "Failed to get those files. Please check the path and try again.",

src/bundles/files/actions.js

-15
Original file line numberDiff line numberDiff line change
@@ -559,21 +559,6 @@ const actions = () => ({
559559
*/
560560
doFilesClear: () => send({ type: ACTIONS.CLEAR_ALL }),
561561

562-
/**
563-
* Gets total size of the local pins. On successful completion `state.mfsSize` will get
564-
* updated.
565-
*/
566-
doPinsStatsGet: () => perform(ACTIONS.PINS_SIZE_GET, async (ipfs) => {
567-
const pinsSize = -1 // TODO: right now calculating size of all pins is too expensive (requires ipfs.files.stat per CID)
568-
let numberOfPins = 0
569-
570-
for await (const _ of ipfs.pin.ls({ type: 'recursive' })) { // eslint-disable-line no-unused-vars
571-
numberOfPins++
572-
}
573-
574-
return { pinsSize, numberOfPins }
575-
}),
576-
577562
/**
578563
* Gets size of the MFS. On successful completion `state.mfsSize` will get
579564
* updated.

src/bundles/notify.js

+11-2
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,17 @@ const notify = {
6969
eventId: `experimentsErrors.${action.payload.key}`
7070
}
7171
}
72+
if (action.type === 'IPFS_PIN_FAILED') {
73+
return {
74+
...state,
75+
show: true,
76+
error: true,
77+
msgArgs: action.msgArgs,
78+
eventId: action.type
79+
}
80+
}
7281

73-
if (action.type === 'IPFS_CONNECT_FAILED' || action.type === 'IPFS_PIN_FAILED') {
82+
if (action.type === 'IPFS_CONNECT_FAILED') {
7483
return {
7584
...state,
7685
show: true,
@@ -119,7 +128,7 @@ const notify = {
119128
return 'ipfsInvalidApiAddress'
120129
}
121130
if (eventId === 'IPFS_PIN_FAILED') {
122-
return 'ipfsPinFail'
131+
return 'ipfsPinFailReason'
123132
}
124133

125134
if (eventId === 'FILES_EVENT_FAILED') {

src/bundles/pinning.js

+76-60
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
11
// @ts-check
22
import { pinningServiceTemplates } from '../constants/pinning'
33
import memoize from 'p-memoize'
4+
import CID from 'cids'
5+
6+
// This bundle leverages createCacheBundle and persistActions for
7+
// the persistence layer that keeps pins in IndexDB store
8+
// to ensure they are around across restarts/reloads/refactors/releases.
9+
10+
const CID_PIN_CHECK_BATCH_SIZE = 10 // Pinata returns error when >10
11+
12+
// id = `${serviceName}:${cid}`
13+
const cacheId2Cid = (id) => id.split(':').at(-1)
14+
const cacheId2ServiceName = (id) => id.split(':').at(0)
415

516
const parseService = async (service, remoteServiceTemplates, ipfs) => {
617
const template = remoteServiceTemplates.find(t => service.endpoint.toString() === t.apiEndpoint.toString())
718
const icon = template?.icon
819
const visitServiceUrl = template?.visitServiceUrl
9-
const autoUpload = await mfsPolicyEnableFlag(service.service, ipfs)
10-
const parsedService = { ...service, name: service.service, icon, visitServiceUrl, autoUpload }
20+
const parsedService = { ...service, name: service.service, icon, visitServiceUrl }
1121

1222
if (service?.stat?.status === 'invalid') {
1323
return { ...parsedService, numberOfPins: -1, online: false }
1424
}
1525

1626
const numberOfPins = service.stat?.pinCount?.pinned
1727
const online = typeof numberOfPins === 'number'
28+
const autoUpload = online ? await mfsPolicyEnableFlag(service.service, ipfs) : undefined
1829

19-
return { ...parsedService, numberOfPins, online }
30+
return { ...parsedService, numberOfPins, online, autoUpload }
2031
}
2132

2233
const mfsPolicyEnableFlag = memoize(async (serviceName, ipfs) => {
@@ -43,35 +54,26 @@ const uniqueCidBatches = (arrayOfCids, size) => {
4354
return result
4455
}
4556

46-
/**
47-
* TODO: This might change, current version from: https://github.com/ipfs/go-ipfs/blob/petar/pincli/core/commands/remotepin.go#L53
48-
* @typedef {Object} RemotePin
49-
* @property {string} id
50-
* @property {string} name
51-
* @property {('queued'|'pinning'|'pinned'|'failed')} status
52-
* @property {string} cid
53-
* @property {Array<string>} [delegates] (multiaddrs endind with /p2p/peerid)
54-
*/
5557
const pinningBundle = {
5658
name: 'pinning',
59+
persistActions: ['UPDATE_REMOTE_PINS'],
5760
reducer: (state = {
5861
pinningServices: [],
5962
remotePins: [],
6063
notRemotePins: [],
6164
arePinningServicesSupported: false
6265
}, action) => {
63-
if (action.type === 'CACHE_REMOTE_PINS') {
64-
const { adds, removals } = action.payload
65-
const remotePins = [...state.remotePins, ...adds].filter(p => !removals.some(r => r === p.id))
66-
const notRemotePins = [...state.notRemotePins, ...removals].filter(rid => !adds.some(a => a.id === rid))
66+
if (action.type === 'UPDATE_REMOTE_PINS') {
67+
const { adds = [], removals = [] } = action.payload
68+
const uniq = (arr) => [...new Set(arr)]
69+
const remotePins = uniq([...state.remotePins, ...adds].filter(p => !removals.some(r => r === p)))
70+
const notRemotePins = uniq([...state.notRemotePins, ...removals].filter(p => !adds.some(a => a === p)))
6771
return { ...state, remotePins, notRemotePins }
6872
}
6973
if (action.type === 'SET_REMOTE_PINNING_SERVICES') {
7074
const oldServices = state.pinningServices
7175
const newServices = action.payload
7276
// Skip update when list length did not change and new one has no stats
73-
// so there is no janky update in 'Set pinning modal' when 3+ services
74-
// are defined and some of them are offline.
7577
if (oldServices.length === newServices.length) {
7678
const withPinStats = s => (s && typeof s.numberOfPins !== 'undefined')
7779
const oldStats = oldServices.some(withPinStats)
@@ -86,73 +88,74 @@ const pinningBundle = {
8688
return state
8789
},
8890

89-
doFetchRemotePins: (files) => async ({ dispatch, store, getIpfs }) => {
90-
// Only check services that are confirmed to be online
91-
const pinningServices = store.selectPinningServices().filter(s => s.online)
92-
91+
doFetchRemotePins: (files, skipCache = false) => async ({ dispatch, store, getIpfs }) => {
92+
const pinningServices = store.selectPinningServices()
9393
if (!pinningServices?.length) return
94-
9594
const ipfs = getIpfs()
96-
9795
if (!ipfs || store?.ipfs?.ipfs?.ready || !ipfs.pin.remote) return
9896

99-
const allCids = files ? files.map(f => f.cid) : []
97+
const allCids = files ? files.map(f => f.cid.toString()) : []
10098

10199
// Reuse known state for some CIDs to avoid unnecessary requests
102-
const cacheId2Cid = (id) => id.split(':').slice(-1)[0]
103-
const remotePins = store.selectRemotePins().map(pin => pin.id)
100+
const remotePins = store.selectRemotePins()
104101
const notRemotePins = store.selectNotRemotePins()
105102

106-
// Check remaining CID status in chunks of 10 (remote API limitation)
107-
const cids = uniqueCidBatches(allCids, 10)
103+
// Check remaining CID status in chunks based on API limitation seen in real world
104+
const cids = uniqueCidBatches(allCids, CID_PIN_CHECK_BATCH_SIZE)
108105

109106
const adds = []
110107
const removals = []
111108

112109
await Promise.allSettled(pinningServices.map(async service => {
113110
try {
114111
// skip CIDs that we know the state of at this service
115-
const skipCids = new Set(
112+
const skipCids = skipCache ? new Set() : new Set(
116113
[...remotePins, ...notRemotePins]
117114
.filter(id => id.startsWith(service.name))
118115
.map(cacheId2Cid)
119116
)
120-
return Promise.allSettled(cids.map(async cidChunk => {
117+
for (const cidChunk of cids) {
121118
const cidsToCheck = cidChunk.filter(cid => !skipCids.has(cid.toString()))
122-
if (!cidsToCheck.length) return // skip if no new cids to check
119+
if (!cidsToCheck.length) continue // skip if no new cids to check
123120
const notPins = new Set(cidsToCheck.map(cid => cid.toString()))
124-
const pins = ipfs.pin.remote.ls({ service: service.name, cid: cidsToCheck })
125-
for await (const pin of pins) {
126-
const pinCid = pin.cid.toString()
127-
notPins.delete(pinCid)
128-
adds.push({ id: `${service.name}:${pinCid}`, ...pin })
121+
try {
122+
/* TODO: wrap pin.remote.*calls with progressive backoff when response Type == "error" and Message includes "429 Too Many Requests"
123+
* and see if we could make go-ipfs include Retry-After header in payload description for this type of error */
124+
const pins = ipfs.pin.remote.ls({ service: service.name, cid: cidsToCheck.map(cid => new CID(cid)) })
125+
for await (const pin of pins) {
126+
const pinCid = pin.cid.toString()
127+
notPins.delete(pinCid)
128+
adds.push(`${service.name}:${pinCid}`)
129+
}
130+
// store 'not pinned remotely on this service' to avoid future checks
131+
} catch (e) {
132+
console.error(`Error: pin.remote.ls service=${service.name} cid=${cidsToCheck}: ${e.toString()}`)
129133
}
130-
// store 'not pinned remotely on this service' to avoid future checks
134+
// cache remaining ones as not pinned
131135
for (const notPinCid of notPins) {
132136
removals.push(`${service.name}:${notPinCid}`)
133137
}
134-
}))
138+
}
135139
} catch (e) {
136140
// ignore service and network errors for now
137141
// and continue checking remaining ones
138142
console.error('unexpected error during doFetchRemotePins', e)
139143
}
140144
}))
141-
dispatch({ type: 'CACHE_REMOTE_PINS', payload: { adds, removals } })
145+
dispatch({ type: 'UPDATE_REMOTE_PINS', payload: { adds, removals } })
142146
},
143147

144148
selectRemotePins: (state) => state.pinning.remotePins || [],
145149
selectNotRemotePins: (state) => state.pinning.notRemotePins || [],
146150

147151
doSelectRemotePinsForFile: (file) => ({ store }) => {
148152
const pinningServicesNames = store.selectPinningServices().map(remote => remote.name)
149-
150-
const remotePinForFile = store.selectRemotePins().filter(pin => pin.cid.string === file.cid.string)
151-
const servicesBeingUsed = remotePinForFile.map(pin => pin.id.split(':')[0]).filter(pinId => pinningServicesNames.includes(pinId))
152-
153+
const remotePinForFile = store.selectRemotePins().filter(pin => cacheId2Cid(pin) === file.cid.toString())
154+
const servicesBeingUsed = remotePinForFile.map(pin => cacheId2ServiceName(pin)).filter(name => pinningServicesNames.includes(name))
153155
return servicesBeingUsed
154156
},
155157

158+
// list of services without online check (reads list from config, should be instant)
156159
doFetchPinningServices: () => async ({ getIpfs, store, dispatch }) => {
157160
const ipfs = getIpfs()
158161
if (!ipfs || store?.ipfs?.ipfs?.ready || !ipfs.pin.remote) return null
@@ -162,14 +165,23 @@ const pinningBundle = {
162165
if (!isPinRemotePresent) return null
163166

164167
const remoteServiceTemplates = store.selectRemoteServiceTemplates()
165-
// list of services without online check (should be instant)
166168
const offlineListOfServices = await ipfs.pin.remote.service.ls()
167169
const remoteServices = await Promise.all(offlineListOfServices.map(service => parseService(service, remoteServiceTemplates, ipfs)))
168170
dispatch({ type: 'SET_REMOTE_PINNING_SERVICES', payload: remoteServices })
169-
// slower list of services + their pin stats (usually slower)
170-
const fullListOfServices = await ipfs.pin.remote.service.ls({ stat: true })
171-
const fullRemoteServices = await Promise.all(fullListOfServices.map(service => parseService(service, remoteServiceTemplates, ipfs)))
172-
dispatch({ type: 'SET_REMOTE_PINNING_SERVICES', payload: fullRemoteServices })
171+
},
172+
173+
// fetching pin stats for services is slower/expensive, so we only do that on Settings
174+
doFetchPinningServicesStats: () => async ({ getIpfs, store, dispatch }) => {
175+
const ipfs = getIpfs()
176+
if (!ipfs || store?.ipfs?.ipfs?.ready || !ipfs.pin.remote) return null
177+
const isPinRemotePresent = (await ipfs.commands()).Subcommands.find(c => c.Name === 'pin').Subcommands.some(c => c.Name === 'remote')
178+
if (!isPinRemotePresent) return null
179+
180+
const remoteServiceTemplates = store.selectRemoteServiceTemplates()
181+
const servicesWithStats = await ipfs.pin.remote.service.ls({ stat: true })
182+
const remoteServices = await Promise.all(servicesWithStats.map(service => parseService(service, remoteServiceTemplates, ipfs)))
183+
184+
dispatch({ type: 'SET_REMOTE_PINNING_SERVICES', payload: remoteServices })
173185
},
174186

175187
selectPinningServices: (state) => state.pinning.pinningServices || [],
@@ -186,54 +198,58 @@ const pinningBundle = {
186198
}
187199
}), {}),
188200

189-
doSetPinning: (pin, services = [], wasLocallyPinned, previousRemotePins = []) => async ({ getIpfs, store, dispatch }) => {
201+
doSetPinning: (file, services = [], wasLocallyPinned, previousRemotePins = []) => async ({ getIpfs, store, dispatch }) => {
190202
const ipfs = getIpfs()
191-
const { cid, name } = pin
203+
const { cid, name } = file
192204

193205
const pinLocally = services.includes('local')
194206
if (wasLocallyPinned !== pinLocally) {
195207
try {
196208
pinLocally ? await ipfs.pin.add(cid) : await ipfs.pin.rm(cid)
197209
} catch (e) {
198210
console.error(`unexpected local pin error for ${cid} (${name})`, e)
199-
dispatch({ type: 'IPFS_PIN_FAILED' })
211+
const msgArgs = { serviceName: 'local', errorMsg: e.toString() }
212+
dispatch({ type: 'IPFS_PIN_FAILED', msgArgs })
200213
}
201214
}
202215

203216
const adds = []
204217
const removals = []
205218

206-
store.selectPinningServices().filter(s => s.online).forEach(async service => {
219+
store.selectPinningServices().forEach(async service => {
207220
const shouldPin = services.includes(service.name)
208221
const wasPinned = previousRemotePins.includes(service.name)
209222
if (wasPinned === shouldPin) return
210223

224+
const id = `${service.name}:${cid}`
211225
try {
212-
const id = `${service.name}:${pin.cid}`
213226
if (shouldPin) {
214-
adds.push({ id, ...pin })
215-
// TODO: remove background:true and add pin job to queue.
216-
// wait for pinning to finish + add indicator for ongoing pinning
227+
adds.push(id)
228+
/* TODO: remove background:true below and add pin job to persisted queue.
229+
* We want track ongoing pinning across restarts of webui/ipfs-desktop
230+
* See: https://github.com/ipfs/ipfs-webui/issues/1752 */
217231
await ipfs.pin.remote.add(cid, { service: service.name, name, background: true })
218232
} else {
219233
removals.push(id)
220234
await ipfs.pin.remote.rm({ cid: [cid], service: service.name })
221235
}
222236
} catch (e) {
223237
// log error and continue with other services
224-
console.error(`unexpected pin.remote error for ${cid}@${service.name}`, e)
225-
dispatch({ type: 'IPFS_PIN_FAILED' })
238+
console.error(`ipfs.pin.remote error for ${cid}@${service.name}`, e)
239+
const msgArgs = { serviceName: service.name, errorMsg: e.toString() }
240+
dispatch({ type: 'IPFS_PIN_FAILED', msgArgs })
226241
}
227242
})
228243

229-
dispatch({ type: 'CACHE_REMOTE_PINS', payload: { adds, removals } })
244+
dispatch({ type: 'UPDATE_REMOTE_PINS', payload: { adds, removals } })
230245

231246
await store.doPinsFetch()
232247
},
233248
doAddPinningService: ({ apiEndpoint, nickname, secretApiKey }) => async ({ getIpfs }) => {
234249
const ipfs = getIpfs()
235250

236251
// temporary mitigation for https://github.com/ipfs/ipfs-webui/issues/1770
252+
// update: still present a year later – i think there is a lesson here :-)
237253
nickname = nickname.replaceAll('.', '_')
238254

239255
await ipfs.pin.remote.service.add(nickname, {

src/components/notify/Notify.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import { withTranslation } from 'react-i18next'
44
import Toast from './Toast'
55

66
const Notify = ({ t, notify, notifyI18nKey, doNotifyDismiss }) => {
7-
const { show, error } = notify
7+
const { show, error, msgArgs } = notify
88
if (!show) return null
99

1010
return (
1111
<Toast error={error} onDismiss={doNotifyDismiss}>
12-
{t(notifyI18nKey)}
12+
{t(notifyI18nKey, msgArgs)}
1313
</Toast>
1414
)
1515
}

0 commit comments

Comments
 (0)