Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 54 additions & 9 deletions packages/@sanity/schema/src/descriptors/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {isEqual, isObject} from 'lodash'

import {Rule} from '../legacy/Rule'
import {OWN_PROPS_NAME} from '../legacy/types/constants'
import {IdleScheduler, type Scheduler, SYNC_SCHEDULER} from './scheduler'
import {
type ArrayElement,
type ArrayTypeDef,
Expand Down Expand Up @@ -59,18 +60,59 @@ export class DescriptorConverter {
*
* This is automatically cached in a weak map.
*/
async get(schema: Schema): Promise<SetSynchronization<RegistryType>> {
async get(
schema: Schema,
opts?: {
/**
* If present, this will use an idle scheduler which records duration into this array.
* This option will be ignored if the `scheduler` option is passed in.
**/
pauseDurations?: number[]

/** An explicit scheduler to do the work. */
scheduler?: Scheduler
},
): Promise<SetSynchronization<RegistryType>> {
/*
Converting the schema into a descriptor consists of two parts:

1. Traversing the type into a descriptor.
2. Serializing the descriptor, including SHA256 hashing.

Note that only (2) can be done in a background worker since the type
itself isn't serializable (which is a requirement for a background
worker). In addition, we expect (2) to scale in the same way as (1): If it
takes X milliseconds to traverse the type into a descriptor it will
probably take c*X milliseconds to serialize it.

This means that a background worker actually doesn't give us that much
value. A huge type will either way be expensive to convert from a type to
a descriptor. Therefore this function currently only avoid blocking by
only processing each type separately.

If we want to minimize the blocking further we would have to restructure
this converter to be able to convert the types asynchronously and _then_
it might make sense to the serialization step itself in a background
worker.
*/
let value = this.cache.get(schema)
if (value) return value

let idleScheduler: IdleScheduler | undefined
const scheduler =
opts?.scheduler ||
(opts?.pauseDurations
? (idleScheduler = new IdleScheduler(opts.pauseDurations))
: SYNC_SCHEDULER)

const options: Options = {
fields: new Map(),
duplicateFields: new Map(),
arrayElements: new Map(),
duplicateArrayElements: new Map(),
}

const namedTypes = schema.getLocalTypeNames().map((name) => {
const namedTypes = await scheduler.map(schema.getLocalTypeNames(), (name) => {
const typeDef = convertTypeDef(schema.get(name)!, name, options)
return {name, typeDef}
})
Expand All @@ -89,24 +131,27 @@ export class DescriptorConverter {
const builder = new SetBuilder({rewriteMap})

// Now we can build the de-duplicated objects:
for (const [fieldDef, key] of options.duplicateFields.entries()) {
await scheduler.forEachIter(options.duplicateFields.entries(), ([fieldDef, key]) => {
builder.addObject('sanity.schema.hoisted', {key, value: {...fieldDef}})
}
})

for (const [arrayElem, key] of options.duplicateArrayElements.entries()) {
await scheduler.forEachIter(options.duplicateArrayElements.entries(), ([arrayElem, key]) => {
builder.addObject('sanity.schema.hoisted', {key, value: {...arrayElem}})
}
})

for (const namedType of namedTypes) {
await scheduler.forEach(namedTypes, (namedType) => {
builder.addObject('sanity.schema.namedType', namedType)
}
})

if (schema.parent) {
builder.addSet(await this.get(schema.parent))
builder.addSet(await this.get(schema.parent, {scheduler}))
}

value = builder.build('sanity.schema.registry')
this.cache.set(schema, value)

// If we created the scheduler we also need to end it.
if (idleScheduler) idleScheduler.end()
return value
}
}
Expand Down
101 changes: 101 additions & 0 deletions packages/@sanity/schema/src/descriptors/scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/** The scheduler is capable of executing work in different ways. */
export type Scheduler = {
map<T, U>(arr: T[], fn: (val: T) => U): Promise<U[]>
forEach<T>(arr: T[], fn: (val: T) => void): Promise<void>
forEachIter<T>(iter: Iterable<T>, fn: (val: T) => void): Promise<void>
}

/**
* How long we're willing to do work before invoking the idle callback.
* This is set to 50% of the budget of maintaining 60 FPS.
*/
const MAX_IDLE_WORK = 0.5 * (1000 / 60)

/** A scheduler which uses an idle callback to process work. */
export class IdleScheduler implements Scheduler {
#durations: number[] = []
#lastAwake: number

constructor(durations: number[]) {
this.#lastAwake = performance.now()
this.#durations = durations
}

async map<T, U>(arr: T[], fn: (val: T) => U): Promise<U[]> {
const result: U[] = []
for (const val of arr) {
const pause = this._tryPause()
if (pause) await pause
result.push(fn(val))
}
return result
}

async forEach<T>(arr: T[], fn: (val: T) => void): Promise<void> {
for (const val of arr) {
const pause = this._tryPause()
if (pause) await pause
fn(val)
}
}

async forEachIter<T>(iter: Iterable<T>, fn: (val: T) => void): Promise<void> {
for (const val of iter) {
const pause = this._tryPause()
if (pause) await pause
fn(val)
}
}

/** Should be invoked at the end to also measure the last pause. */
end() {
this.#durations.push(performance.now() - this.#lastAwake)
}

/**
* Yields control back to the UI.
*/
private _tryPause(): Promise<void> | undefined {
// Record how much time we've used so far:
const now = performance.now()
const elapsed = now - this.#lastAwake
if (elapsed < MAX_IDLE_WORK) {
// We're willing to do more work!
return undefined
}

this.#durations.push(elapsed)

return new Promise((resolve) => {
const done = () => {
this.#lastAwake = performance.now()
resolve()
}

if (typeof requestIdleCallback === 'function') {
requestIdleCallback(done, {timeout: 1})
} else if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(done)
} else {
setTimeout(done, 0)
}
})
}
}

/** A scheduler which does the work as synchronous as possible. */
export const SYNC_SCHEDULER: Scheduler = {
async map<T, U>(arr: T[], fn: (val: T) => U): Promise<U[]> {
return arr.map(fn)
},

async forEach<T>(arr: T[], fn: (val: T) => void): Promise<void> {
return arr.forEach(fn)
},

async forEachIter<T>(iter: Iterable<T>, fn: (val: T) => void): Promise<void> {
for (const val of iter) {
fn(val)
}
},
}
28 changes: 25 additions & 3 deletions packages/sanity/src/core/config/uploadSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from '@sanity/schema/_internal'
import {type Schema} from '@sanity/types'
import debugit from 'debug'
import {max, sum} from 'lodash'
import {firstValueFrom} from 'rxjs'

import {isDev} from '../environment'
Expand All @@ -13,7 +14,7 @@ import {DESCRIPTOR_CONVERTER} from '../schema'

const debug = debugit('sanity:config')

const TOGGLE = 'toggle.schema.upload'
const TOGGLE = 'toggle.schema.upload-pause'

async function isEnabled(client: SanityClient): Promise<boolean> {
if (typeof process !== 'undefined' && process?.env?.SANITY_STUDIO_SCHEMA_DESCRIPTOR) {
Expand Down Expand Up @@ -72,28 +73,49 @@ export async function uploadSchema(
// The second step is then to actually synchronize it. This is a multi-step
// process where it tries to synchronize as much as possible in each step.

const pauseDurations: number[] = []
const before = performance.now()
const sync = await DESCRIPTOR_CONVERTER.get(schema)
const sync = await DESCRIPTOR_CONVERTER.get(schema, {pauseDurations})
const after = performance.now()

const totalPause = sum(pauseDurations) || 0
const maxPause = max(pauseDurations) || 0
const avgPause = pauseDurations.length === 0 ? 0 : totalPause / pauseDurations.length
const duration = after - before

if (duration > 1000) {
console.warn(`Building schema for synchronization took more than 1 second (${duration}ms)`)
}

if (maxPause > 100) {
console.warn(
`Building schema for synchronization blocked UI for more than 100ms (${maxPause}ms)`,
)
}

const descriptorId = sync.set.id
const {projectId = '?', dataset = '?'} = client.config()
let contextKey = `dataset:${projectId}:${dataset}`
if (isDev) contextKey += '#dev'

const claimRequest: ClaimRequest = {descriptorId, contextKey}

const clientTimings = {
convertSchema: duration,
convertSchemaPauseTotal: totalPause,
convertSchemaPauseMax: maxPause,
convertSchemaPauseAvg: avgPause,
}

const claimResponse = await client.request<ClaimResponse>({
uri: '/descriptors/claim',
method: 'POST',
body: claimRequest,
headers: {
// We mirror the format of Server-Timing: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing
'Client-Timing': `convertSchema;dur=${duration}`,
'Client-Timing': Object.entries(clientTimings)
.map(([name, dur]) => `${name};dur=${dur}`)
.join(','),
},
})

Expand Down
Loading