Skip to content

openapi: file-name chains into one wide mapping of the source take quadratic time #775

Description

@OmarAlJarrah

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions