mirror of
https://github.com/keymanapp/keyman.git
synced 2026-10-07 22:47:29 +00:00
feat(web): queued promise prioritizer / resynchronizer
This commit is contained in:
parent
50543d601e
commit
ea775d9ebf
2 changed files with 238 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue