Skip to content

Commit cacefa6

Browse files
SharonStratsGPT-5.6 Luna
andcommitted
fix: deduplicate concurrent Solid document loads
Prompt: Share simultaneous plain document loads in solid-logic so startup callers reuse one in-flight request, including loads that rdflib upgrades internally after an authentication transition. Do not share forced, option-bearing, or batch loads, and clear entries when requests settle so explicit refreshes and retries retain their existing behavior. Add regression coverage for concurrent sharing, failed-load retries, and excluded load modes. Co-authored-by: GPT-5.6 Luna <gpt-5.6-luna@openai.com>
1 parent 961a6b3 commit cacefa6

4 files changed

Lines changed: 165 additions & 2 deletions

File tree

‎src/authSession/flagAuthorizationOnTransitions.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,16 @@ function invalidate (store: AuthorizationStore): boolean {
6969
*/
7070
export function flagAuthorizationOnSessionTransitions (
7171
store: TransitionStore,
72-
session: SessionLike
72+
session: SessionLike,
73+
onIdentityTransition?: () => void
7374
): () => void {
7475
const onTransition = (): void => {
7576
const state = storeState(store)
7677
// Whose credentials a request would carry changed: every answer recorded
7778
// under the previous identity is suspect from here on.
7879
state.generation += 1
7980
state.invalidationFailed = !invalidate(store)
81+
onIdentityTransition?.()
8082
}
8183
const subscription = subscribeIdentity(session, { onTransition })
8284
return () => subscription.unsubscribe()

‎src/logic/solidLogic.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createContainerLogic } from '../util/containerLogic'
1414
import { createUtilityLogic } from '../util/utilityLogic'
1515
import type { AuthnLogic, SolidLogic } from '../types'
1616
import * as debug from '../util/debug'
17+
import { shareConcurrentLoads } from '../util/shareConcurrentLoads'
1718
/*
1819
** It is important to distinquish `fetch`, a function provided by the browser
1920
** and `Fetcher`, a helper object for the rdflib Store which turns it
@@ -25,13 +26,14 @@ export function createSolidLogic(specialFetch: { fetch: (url: any, requestInit:
2526
debug.log('SolidLogic: Unique instance created. There should only be one of these.')
2627
const store: LiveStore = rdf.graph() as LiveStore
2728
rdf.fetcher(store, {fetch: specialFetch.fetch}) // Attach a web I/O module, store.fetcher
29+
const invalidateSharedLoads = shareConcurrentLoads(store.fetcher)
2830
store.updater = new rdf.UpdateManager(store) // Add real-time live updates store.updater
2931
store.features = [] // disable automatic node merging on store load
3032
// Whose credentials a request would carry changed: mark every recorded
3133
// response out-of-date so editability answers "unknown" instead of the
3234
// previous identity's access. Decision points repair with
3335
// ensureDocumentAuthorization() (see flagAuthorizationOnTransitions.ts).
34-
const unsubscribeAuthorization = flagAuthorizationOnSessionTransitions(store, session)
36+
const unsubscribeAuthorization = flagAuthorizationOnSessionTransitions(store, session, invalidateSharedLoads)
3537

3638
const authn: AuthnLogic = new SolidAuthnLogic(session)
3739

‎src/util/shareConcurrentLoads.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { Fetcher, NamedNode } from 'rdflib'
2+
3+
type LoadTarget = Parameters<Fetcher['load']>[0]
4+
type IdentityTransition = () => void
5+
6+
function plainLoadKey (target: LoadTarget, options: unknown): string | undefined {
7+
if (options !== undefined && (typeof options !== 'object' || options === null || Object.keys(options).length > 0)) {
8+
return undefined
9+
}
10+
const uri = typeof target === 'string'
11+
? target
12+
: (target as NamedNode | undefined)?.termType === 'NamedNode' ? (target as NamedNode).value : undefined
13+
return uri?.split('#')[0]
14+
}
15+
16+
/**
17+
* Concurrent plain `load(doc)` calls share one request.
18+
*
19+
* rdflib already shares in-flight loads, except when it upgrades a load to
20+
* `force` itself: after login every earlier answer is flagged out-of-date, so
21+
* each startup caller of e.g. the profile document sent its own GET. Calls
22+
* with options (force, headers, ...) are not shared, and an entry only lives
23+
* while its request is pending, so later loads behave as before.
24+
*/
25+
export function shareConcurrentLoads (fetcher: Fetcher): IdentityTransition {
26+
const originalLoad = fetcher.load.bind(fetcher)
27+
const pending = new Map<string, Promise<unknown>>()
28+
29+
fetcher.load = function (target: LoadTarget, options?: unknown) {
30+
if (Array.isArray(target)) {
31+
return Promise.all(target.map(member => (originalLoad as any)(member, options))) as any
32+
}
33+
const key = plainLoadKey(target, options)
34+
if (!key) return (originalLoad as any)(target, options)
35+
36+
const existing = pending.get(key)
37+
if (existing) return existing as any
38+
39+
const request = Promise.resolve((originalLoad as any)(target, options))
40+
.finally(() => {
41+
if (pending.get(key) === request) pending.delete(key)
42+
})
43+
pending.set(key, request)
44+
return request as any
45+
} as Fetcher['load']
46+
47+
return () => {
48+
pending.clear()
49+
}
50+
}

‎test/shareConcurrentLoads.test.ts‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import * as rdf from 'rdflib'
3+
import { shareConcurrentLoads } from '../src/util/shareConcurrentLoads'
4+
5+
function deferred () {
6+
let resolve!: (value: unknown) => void
7+
let reject!: (error: unknown) => void
8+
const promise = new Promise((res, rej) => { resolve = res; reject = rej })
9+
return { promise, resolve, reject }
10+
}
11+
12+
function fetcherWith (load: (...args: unknown[]) => Promise<unknown>) {
13+
const fetcher = { load } as unknown as rdf.Fetcher
14+
shareConcurrentLoads(fetcher)
15+
return fetcher
16+
}
17+
18+
describe('shareConcurrentLoads', () => {
19+
const doc = rdf.sym('https://alice.example/profile/card')
20+
21+
it('shares one request between concurrent plain loads of the same document', async () => {
22+
const response = deferred()
23+
const load = vi.fn(() => response.promise)
24+
const fetcher = fetcherWith(load)
25+
26+
const first = fetcher.load(doc)
27+
const second = fetcher.load(rdf.sym('https://alice.example/profile/card#me').doc())
28+
const third = fetcher.load('https://alice.example/profile/card#me', {})
29+
response.resolve('ok')
30+
31+
await expect(Promise.all([first, second, third])).resolves.toEqual(['ok', 'ok', 'ok'])
32+
expect(load).toHaveBeenCalledTimes(1)
33+
})
34+
35+
it('loads again once the shared request has settled', async () => {
36+
const load = vi.fn(async () => 'ok')
37+
const fetcher = fetcherWith(load)
38+
39+
await fetcher.load(doc)
40+
await fetcher.load(doc)
41+
42+
expect(load).toHaveBeenCalledTimes(2)
43+
})
44+
45+
it('lets a failed load be retried', async () => {
46+
const load = vi.fn()
47+
.mockRejectedValueOnce(new Error('offline'))
48+
.mockResolvedValueOnce('ok')
49+
const fetcher = fetcherWith(load)
50+
51+
await expect(fetcher.load(doc)).rejects.toThrow('offline')
52+
await expect(fetcher.load(doc)).resolves.toBe('ok')
53+
})
54+
55+
it('does not share forced loads, loads with options, or batches', async () => {
56+
const response = deferred()
57+
const load = vi.fn(() => response.promise)
58+
const fetcher = fetcherWith(load)
59+
60+
const plain = fetcher.load(doc)
61+
const forced = fetcher.load(doc, { force: true })
62+
const withHeaders = fetcher.load(doc, { headers: { accept: 'text/turtle' } } as any)
63+
const batch = fetcher.load([doc])
64+
response.resolve('ok')
65+
await Promise.all([plain, forced, withHeaders, batch])
66+
67+
expect(load).toHaveBeenCalledTimes(4)
68+
})
69+
70+
it('does not let a batch re-enter the sharing wrapper', async () => {
71+
const load = vi.fn(async (target: unknown) => {
72+
if (Array.isArray(target)) {
73+
return Promise.all(target.map(member => (fetcher.load as any)(member)))
74+
}
75+
return target
76+
})
77+
const fetcher = fetcherWith(load)
78+
79+
await expect(fetcher.load([doc, rdf.sym('https://bob.example/profile/card')])).resolves.toHaveLength(2)
80+
81+
expect(load).toHaveBeenCalledTimes(2)
82+
expect(load.mock.calls.every(([target]) => !Array.isArray(target))).toBe(true)
83+
})
84+
85+
it('does not share a pending request across an identity transition', async () => {
86+
const response = deferred()
87+
const load = vi.fn(() => response.promise)
88+
const fetcher = { load } as unknown as rdf.Fetcher
89+
const invalidate = shareConcurrentLoads(fetcher)
90+
91+
const beforeTransition = fetcher.load(doc)
92+
invalidate()
93+
const afterTransition = fetcher.load(doc)
94+
response.resolve('old identity')
95+
96+
expect(await beforeTransition).toBe('old identity')
97+
expect(load).toHaveBeenCalledTimes(2)
98+
expect(await afterTransition).toBe('old identity')
99+
})
100+
101+
it('does not share loads of different documents', async () => {
102+
const load = vi.fn(async () => 'ok')
103+
const fetcher = fetcherWith(load)
104+
105+
await Promise.all([fetcher.load(doc), fetcher.load(rdf.sym('https://bob.example/profile/card'))])
106+
107+
expect(load).toHaveBeenCalledTimes(2)
108+
})
109+
})

0 commit comments

Comments
 (0)