Problem
With allow-external-refs=true, a schema $ref that names the source by its file name (root.yaml#/x-lib/C1) is read in the source's tree. There, jsonpointer.GetTarget finds a key by comparing the keys of the mapping that holds it, in order. Two readers repeat that read for every hop and charge it by the hop, whatever the mapping's width:
So n chains into one mapping of n keys take time quadratic in n, bounded only by hop counts. #774 (for #773) prices the same reads where settle resumes a chain in another document's tree, by the keys they compare (treeReads). These readers read the source and resume nothing, so it leaves them as they are.
Reproduction
Verified at 25bdace. gen.py writes root.yaml, whose schemas each name an entry of its own x-lib by the file name, and each entry names another in the same mapping:
import sys
n = int(sys.argv[1])
root = ["openapi: 3.1.0", "info: {title: T, version: '1'}", "paths: {}", "components:", " schemas:"]
root += [f" S{i}: {{$ref: 'root.yaml#/x-lib/C{i}'}}" for i in range(n)]
root += ["x-lib:"]
root += [f" C{i}: {{$ref: '#/x-lib/D{i}'}}" for i in range(n)]
root += [f" D{i}: {{type: object}}" for i in range(n)]
open("root.yaml", "w").write("\n".join(root) + "\n")
python3 gen.py 20000, then /usr/bin/time -l morphic validate root.yaml --opt allow-external-refs=true (macOS). The last column is a build of 25bdace whose loops.read returns at once:
| chains |
refs on |
refs off |
refs on, loops.read returning at once |
| 5,000 |
1.48 s |
0.32 s |
1.20 s |
| 10,000 |
4.74 s |
0.94 s |
3.37 s |
| 20,000 |
13.4 s |
2.60 s |
9.11 s |
With references off, each file-name $ref is refused (external reference not allowed). With them on, every chain resolves and nothing is reported. The branch of #774 times the same (13.35 s at 20,000) and reports no budget crossed.
Cause
loops accounts for about a third of the time at 20,000 chains, which the last column shows. Without it, the rest still grows faster than linearly. This issue does not break the rest down.
Proposal
Price the tree reads loops.readAt and mappings.readAt make with treeReads (#774), and charge their budgets in keys rather than hops. readAt's existence check could reuse readHop's read instead of making its own. Then profile what remains before deciding whether the resolver's own reads of these hops need a bound.
Acceptance
- The keys
loops and chainEnds compare when they read the source's tree are charged against a bound.
- 20,000 file-name chains into one mapping either finish within a small factor of the references-off time, or stop at a bound with a diagnostic that says so.
Update: where the time goes
A single-threaded CPU profile (GOMAXPROCS=1, runtime/pprof in a standalone program) of the 20,000-chain run at 25bdace, 13.4 s in all:
| Where |
Time |
the library resolving each file-name $ref (oas3 Resolve) |
2.9 s |
loops.read: readAt reads each tree hop twice, once for its existence check and once in readHop |
1.8 s |
the lowering's resolve.Scope.ModelAt, scanning x-lib for each $ref it hoists (#778) |
1.7 s |
annotation.checkUniqueKeys on x-lib (#776) |
1.7 s |
| GC and the rest |
the remainder |
So this issue covers loops and mappings.chainEnds. The library's own reads are out of reach here.
Problem
With
allow-external-refs=true, a schema$refthat names the source by its file name (root.yaml#/x-lib/C1) is read in the source's tree. There,jsonpointer.GetTargetfinds a key by comparing the keys of the mapping that holds it, in order. Two readers repeat that read for every hop and charge it by the hop, whatever the mapping's width:loops.read(openapi: a $ref cycle closing through the source's own file name overflows the stack #768) reads each hop of such a chain in the tree twice, once inreadAt's check that the tree holds the pointer and once inreadHop. It counts one hop againstmaxLoopReads(2¹⁸).mappings.chainEndsreads a mapping target's hops the same way, for one step each ofmaxMappingWork.So n chains into one mapping of n keys take time quadratic in n, bounded only by hop counts. #774 (for #773) prices the same reads where
settleresumes a chain in another document's tree, by the keys they compare (treeReads). These readers read the source and resume nothing, so it leaves them as they are.Reproduction
Verified at
25bdace.gen.pywritesroot.yaml, whose schemas each name an entry of its ownx-libby the file name, and each entry names another in the same mapping:python3 gen.py 20000, then/usr/bin/time -l morphic validate root.yaml --opt allow-external-refs=true(macOS). The last column is a build of25bdacewhoseloops.readreturns at once:loops.readreturning at onceWith references off, each file-name
$refis refused (external reference not allowed). With them on, every chain resolves and nothing is reported. The branch of #774 times the same (13.35 s at 20,000) and reports no budget crossed.Cause
loopsaccounts for about a third of the time at 20,000 chains, which the last column shows. Without it, the rest still grows faster than linearly. This issue does not break the rest down.Proposal
Price the tree reads
loops.readAtandmappings.readAtmake withtreeReads(#774), and charge their budgets in keys rather than hops.readAt's existence check could reusereadHop's read instead of making its own. Then profile what remains before deciding whether the resolver's own reads of these hops need a bound.Acceptance
loopsandchainEndscompare when they read the source's tree are charged against a bound.Update: where the time goes
A single-threaded CPU profile (
GOMAXPROCS=1,runtime/pprofin a standalone program) of the 20,000-chain run at25bdace, 13.4 s in all:$ref(oas3Resolve)loops.read:readAtreads each tree hop twice, once for its existence check and once inreadHopresolve.Scope.ModelAt, scanningx-libfor each$refit hoists (#778)annotation.checkUniqueKeysonx-lib(#776)So this issue covers
loopsandmappings.chainEnds. The library's own reads are out of reach here.