feat(web): queued promise prioritizer / resynchronizer

This commit is contained in:
Joshua A. Horton 2023-08-11 12:35:23 +07:00
parent 50543d601e
commit ea775d9ebf
2 changed files with 238 additions and 0 deletions

View file

@ -0,0 +1,152 @@
interface Entry {
priority: number,
ordinal: number
}
interface SynchronizationSet {
entries: Entry[],
promise: Promise<void>
}
/*
* This was "fun".
*
* Collects metadata on all near-synchronous calls to its lone public method, intended to handle
* cases where Promise fulfillment is essentially triggered synchronously. JS has an internal
* "Promise queue" that ensures consistent ordering with Promise resolution and fulfillment,
* assuming all Promises of equal priority. Its func exists to customize their order of
* resolution when fulfilled "synchronously", based on a supplied `priority` value.
* - Common case: a gesture-source path ends (touchend, mouseup)
* - Any promises based on completing the path will need to immediately either reject or fulfill.
* - Depending on implementation, these will be registered on the queue synchronously.
* - This is _absolutely the case_ through our use of the matchers' use of the
* `ManagedPromise` utility class.
* - If certain ones should be given the chance to "resolve" first, regardless of when their
* Promises were initialized, the built-in Promise queue doesn't handle _that_.
*
* By re-deferring on an instantly-resolving Promise, we can note which calls to this method are
* essentially synchronous. During the corresponding await, we can construct additional call-
* specific Promises as handles for our callers and track their priority for sorting.
*
* Once we reach resolution of that instantly-resolving Promise (`syncPromise`), we lock out
* any further calls from the set to be processed; new incoming entries will fall into a later
* set regardless of priority. With the syncSet's recording fully completed... We may then
* sort the known "synchronous" calls to this resolution handler in order of priority, thereby
* enforcing our desired Promise-resolution ordering.
*/
/**
* The short version: this is a Promise "resynchronizer" that allows us to choose the order in which
* "synchronous" promises resolve.
*
* To clarify: if one external function call triggers direct resolution of three separate Promises
* that instantly queue against this class's `queueWithPriority` method, they are considered
* "synchronous" - as they're all placed on the queue before the queue regains control and before
* the first of the three Promises signals its resolution.
*
* If each of the three then awaits its turn via said queueing, they will each then resolve in order
* of their specified priority.
*/
export class QueuedPromisePrioritizer {
private currentSyncPromise: Promise<void>;
private setQueue: SynchronizationSet[] = [];
private ordinalSeed: number = 0;
/**
* This method generates a new Promise on the JS Promise queue that will resolve in custom order
* relative to other calls against this method that occur as of a direct, synchronous result of
* Promises already queued for fulfillment.
* - If there are three such Promises queued that each call this method, the new Promise's
* "instant resolution" is queued (1) after the third call queues itself AND (2) after any
* of the others that queue with a higher priority level have received their turns.
*
* Any future calls caused by Promise fulfillment not yet queued up, as of the "first" (w.l.o.g)
* call against this method, must await their turns until after the lowest priority call from
* Promise fulfillments already queued has received its turn.
* - To continue the same example, if a timeout of 1 ms is set after the third queuing call
* and only then a fourth call for promise-queuing occurs, the fourth will always resolve
* after all three prior calls resolve, no matter what priority value is supplied.
*
* @param priority
*/
async queueWithPriority(priority: number) {
// If we're not in the middle of recognizing a set of simultaneously-queued Promises,
// time to enter that state.
if(!this.currentSyncPromise) {
const syncMetadata: SynchronizationSet = {
entries: [],
promise: this.currentSyncPromise = Promise.resolve()
};
/*
* Once we've finished recording the set, we can sort the Promises by priority order - larger
* values first. No need to sort it every time, after all - no new entries will be allowed
* at that point.
*/
this.currentSyncPromise.then(() => {
// Descending priority order for _our_ syncSet. We should only need to do it once, and can do
// it once collection is complete (see `await` below).
syncMetadata.entries.sort((a, b) => {
let primary = b.priority - a.priority;
if(primary) {
return primary;
}
// If equal priority, the one that registered first wins. It's an easy enough guarantee
// to make, so why not?
return a.ordinal - b.ordinal;
});
});
this.setQueue.push(syncMetadata);
}
// Capture the current sync-Promise
const syncSet = this.setQueue[this.setQueue.length-1];
// Register our tracking metadata for this queue request.
const entry: Entry = {
priority: priority,
ordinal: this.ordinalSeed++
}
syncSet.entries.push(entry);
// Now that we've recorded the request's metadata...
await syncSet.promise; // which is Promise.resolve(), and thus already queued for fulfillment.
/*
* As the line above is the first `await` within this method, only now can other asynchronous
* things have a chance to execute and/or add new Promises to the queue. We've already registered
* our synchronization Promise and reserved our slot in the Promise queue, allowing us to meet
* our guarantees.
*
* As a result, registration is now over and done for Promises allowed to be treated as
* synchronous. Any new calls from new Promises - or even from resolution handlers for existing
* ones - will always reach this point in this function later, effectively after our denoted
* queue-split point, and definitely "asynchronously".
*
* By clearing the current syncSet marker, this function will know to start a new, separate `syncSet`.
* (See: this method's first code block / conditional on this field)
*/
this.currentSyncPromise = null;
// // #1 - make sure our sync set isn't deferred pending completion of a different syncSet.
// #2 - if we're not the highest priority here... re-defer to let the higher-priority
// entries go first.
while(this.setQueue[0].entries[0] != entry) {
await syncSet.promise;
}
// It's our turn now! But first, remove our entries in the queue to make sure the next guy
// in the queue gets his turn / can exit the while-loop above.
syncSet.entries.splice(0, 1);
if(syncSet.entries.length == 0) {
this.setQueue.splice(0, 1);
}
// And we're in the clear!
return;
}
}

View file

@ -0,0 +1,86 @@
import { assert } from 'chai'
import { timedPromise } from '@keymanapp/web-utils';
import { QueuedPromisePrioritizer } from "@keymanapp/gesture-recognizer";
describe('QueuedPromisePrioritizer', () => {
it('single call', async () => {
const prioritizer = new QueuedPromisePrioritizer();
await prioritizer.queueWithPriority(1);
});
it('three sync', async () => {
const prioritizer = new QueuedPromisePrioritizer();
const simpleLog: number[] = [];
const waiter1 = prioritizer.queueWithPriority(1).then(() => simpleLog.push(1));
const waiter2 = prioritizer.queueWithPriority(2).then(() => simpleLog.push(2));
const waiter3 = prioritizer.queueWithPriority(3).then(() => simpleLog.push(3));
await Promise.all([waiter1, waiter2, waiter3]);
assert.sameOrderedMembers(simpleLog, [3, 2, 1]);
});
// Because JS's default Array.sort isn't a stable-sort.
it('three equal-priority sync', async () => {
const prioritizer = new QueuedPromisePrioritizer();
const simpleLog: number[] = [];
const waiter1 = prioritizer.queueWithPriority(1).then(() => simpleLog.push(1));
const waiter2 = prioritizer.queueWithPriority(1).then(() => simpleLog.push(2));
const waiter3 = prioritizer.queueWithPriority(1).then(() => simpleLog.push(3));
await Promise.all([waiter1, waiter2, waiter3]);
assert.sameOrderedMembers(simpleLog, [1, 2, 3]);
});
it('three sync, time wait, two sync', async () => {
const prioritizer = new QueuedPromisePrioritizer();
const simpleLog: number[] = [];
const waiter1 = prioritizer.queueWithPriority(1).then(() => simpleLog.push(1));
const waiter2 = prioritizer.queueWithPriority(-2).then(() => simpleLog.push(-2));
const waiter3 = prioritizer.queueWithPriority(3).then(() => simpleLog.push(3));
await timedPromise(10);
const waiter4 = prioritizer.queueWithPriority(15).then(() => simpleLog.push(15));
const waiter5 = prioritizer.queueWithPriority(0).then(() => simpleLog.push(0));
await Promise.all([waiter1, waiter2, waiter3, waiter4, waiter5]);
assert.sameOrderedMembers(simpleLog, [3, 1, -2, 15, 0]);
});
// The most brutal test / niche edge case.
it('three sync, await-and-call, two sync', async () => {
const prioritizer = new QueuedPromisePrioritizer();
const simpleLog: number[] = [];
const waiter1 = prioritizer.queueWithPriority(1).then(() => simpleLog.push(1));
const waiter2 = prioritizer.queueWithPriority(-2).then(() => simpleLog.push(-2));
const waiter3 = prioritizer.queueWithPriority(3).then(() => simpleLog.push(3));
// Is within its own set; we do have to await full resolution of the Promise.resolve() call, which takes its
// own slot on the queue. As the queue takes control at this stage, further registrations (from the `then`)
// will be treated as asynchronous with the prior set.
//
// We must _then_ await its complete fulfillment - our await is actually on the `then` clause.
const waiter4 = await Promise.resolve().then(() => prioritizer.queueWithPriority(4).then(() => simpleLog.push(4)));
// As we `awaited`, we get control back from the queue asynchronously relative to the priority-4 call's
// registration.
const waiter5 = prioritizer.queueWithPriority(15).then(() => simpleLog.push(15));
const waiter6 = prioritizer.queueWithPriority(0).then(() => simpleLog.push(0));
await Promise.all([waiter1, waiter2, waiter3, waiter4, waiter5, waiter6]);
assert.sameOrderedMembers(simpleLog, [3, 1, -2, 4, 15, 0]);
});
});