-3

Anticipated FAQ:

  • Yes, I know what a Promise is.
  • No, I can't simply move the init logic to the constructor. It needs to be called in the initMethod because the initMethod is a hook that needs to be called at a certain time.

Sorry, it's just that I saw some similar questions marked as "duplicate", so I wanted to put these FAQ at the top.

Question

My issue is the following race condition:

class Service {

  private x: string | null = null;

  initMethod() {
    this.x = 'hello';
  }

  async methodA() {
    return this.x.length;
  }
}

const service = new Service();
setTimeout(() => service.initMethod(), 1000);
service.methodA().catch(console.log);
TypeError: Cannot read properties of null (reading 'length')
    at Service.methodA (<anonymous>:15:19)
    at <anonymous>:20:9
    at dn (<anonymous>:16:5449)

I need something like a Promise whose settled value can be set from another part of the code. Something like:

class Service {

  private x: SettablePromise<string> = new SettablePromise();

  initMethod() {
    this.x.set('hello');
  }

  async methodA() {
    return (await this.x).length;
  }
}

const service = new Service();
setTimeout(() => service.initMethod(), 1000);
service.methodA().catch(console.log);

The best I can come up with is to make a class that polls a value until it turns non-null. I'm hoping there's something smarter. I don't want to have to fine-tune a poll interval.

Edits

Sorry for the initial confusing example. The race condition is that methodA can be called before initMethod.

There was also an unnecessary async in the initMethod. I just made it async because the real method it was based on is async.

  • 2
    Assign the Promise to an instance property, then call `.then` on it? – CertainPerformance Feb 16 '23 at 00:37
  • When _is_ `initMethod()` called? What should happen if `methodA()` is called first?. Please [edit] your question into a [minimal, reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) – Phil Feb 16 '23 at 00:43
  • @CertainPerformance how would I set the resolve value? After I create the promise, I don't see a way for me to set the value in `initMethod`. Also, I want to use `await` instead of converting `methodA` into using callback logic to access the promise value. – José Alvarado Torre Feb 16 '23 at 00:45
  • @JoséAlvaradoTorre The existing answer does everything you've asked for, using a promise. – user229044 Feb 16 '23 at 00:46
  • @Phil I did make a MRE. Just pop the first code into a typescript playground. I specified that this was a race condition. `methodA` may get invoked first. It needs to `await` until `this.x` is set. – José Alvarado Torre Feb 16 '23 at 00:47
  • Nothing calls `initMethod()` in your question – Phil Feb 16 '23 at 00:48
  • @Phil I see the confusion, sorry. You can just assume that `initMethod` gets called after `methodA`. Let me make an edit. – José Alvarado Torre Feb 16 '23 at 00:54
  • `async initMethod() { this.x = "hello" }` what do you think this does? why did you label it `async`? – Mulan Feb 16 '23 at 00:58
  • @Mulan in this case, the `async` is unnecessary, so I'll remove it. I was just defaulting to the signature in the real code. – José Alvarado Torre Feb 16 '23 at 01:00

3 Answers3

2

In the following example, you can run the init before or after the async method call. Either will work -

const s = new Service()
// init
s.init()
// then query
s.query("SELECT * FROM evil").then(console.log)
const s = new Service()
// query first
s.query("SELECT * FROM evil").then(console.log)
// then init
s.init()

deferred

The solution begins with a generic deferred value that allows us to externally resolve or reject a promise -

function deferred() {
  let resolve, reject
  const promise = new Promise((res,rej) => {
    resolve = res
    reject = rej
  })
  return { promise, resolve, reject }
}

service

Now we will write Service which has a resource deferred value. The init method will resolve the resource at some point in time. The asynchronous method query will await the resource before it proceeds -

class Service {
  resource = deferred() // deferred resource
  async init() {
    this.resource.resolve(await connect()) // resolve resource
  }
  async query(input) {
    const db = await this.resource.promise // await resource
    return db.query(input)
  }
}

connect

This is just some example operation that we run in the init method. It returns an object with a query method that mocks a database call -

async function connect() {
  await sleep(2000) // fake delay
  return {
    query: (input) => {
      console.log("running query:", input)
      return ["hello", "world"] // mock data result
    }
  }
}

function sleep(ms) {
  return new Promise(r => setTimeout(r, ms))
}

demo

function deferred() {
  let resolve, reject
  const promise = new Promise((res,rej) => {
    resolve = res
    reject = rej
  })
  return { promise, resolve, reject }
}

class Service {
  resource = deferred()
  async init() {
    this.resource.resolve(await connect())
  }
  async query(input) {
    const db = await this.resource.promise
    return db.query(input)
  }
}

async function connect() {
  await sleep(2000)
  return {
    query: (input) => {
      console.log("running query:", input)
      return ["hello", "world"]
    }
  }
}

function sleep(ms) {
  return new Promise(r => setTimeout(r, ms))
}

const s = new Service()
s.query("SELECT * FROM evil").then(console.log)
s.init()

always handle rejections

If init fails to connect to the database, we need to reflect that with the resource, otherwise our program will hang indefinitely -

class Service {
  resource = deferred()
  async init() {
    try {
      const db = await timeout(connect(), 5000) // timeout
      this.resource.resolve(db)
    }
    catch (err) {
      this.resource.reject(err) // reject error
    }
  }
  async query(input) {
    const db = await timeout(this.resource.promise, 5000) // timeout
    return db.query(input)
  }
}

function timeout(p, ms) {
  return Promise.race([
    p,
    sleep(ms).then(() => { throw Error("timeout") }),
  ])
}
Mulan
  • 129,518
  • 31
  • 228
  • 259
  • Thanks the detailed answer! This is my favorite because it encapsulates the logic into a `deferred` object that is 1) reusable and 2) doesn't let the caller make the mistake of failing to await a "lock" promise before reading a value. Good job! – José Alvarado Torre Feb 16 '23 at 01:26
  • very happy to help :) if the underlying resource becomes disconnected or you would like to reinitialize, you can set `this.resource = deferred()` and all asynchronous `query` calls will await the new resource. – Mulan Feb 16 '23 at 01:33
1

As I understand it, you need to have a promise settled within constructor but it have to be resolved only when initMethod is called.

You can expose a promise resolver alongside the promise :

class Service {
 private x: string | null = null;
  private promise;
  private resolve;

  constructor() {
     this.promise = new Promise(resolve => this.resolve = resolve);
  }

  async initMethod() {
    // Do your async stuff
    await new Promise(resolve => setTimeout(resolve, 1000));
    this.x = 'hello';
    // And resolve Service promise
    this.resolve();
  }

  async methodA() {
    await this.promise;
    return this.x.length;
  }
}

See Resolve Javascript Promise outside the Promise constructor scope for more examples.

Bertrand
  • 1,840
  • 14
  • 22
  • That'll work, ty! However, I would ideally avoid requiring the client to await one thing and access another. It opens the door to bugs where some other developer immediately checks the value and doesn't await the associated promise. – José Alvarado Torre Feb 16 '23 at 01:14
1

You need to initialise a property with a Promise value that only resolves after initMethod() has completed.

This involves also maintaining the promise's resolve callback as a class property.

class Service {

  #initResolve;
  #initPromise = new Promise((resolve) => {
    this.#initResolve = resolve;
  });
  
  #x = null;

  async initMethod() {
    await new Promise(resolve => setTimeout(resolve, 1000));
    this.#x = 'hello';
    this.#initResolve(); // resolve #initPromise
  }

  async methodA() {
    await this.#initPromise;
    return this.#x.length;
  }
}

const service = new Service();
console.log("methodA: start");
service.methodA().then(console.log.bind(console, "methodA: end:"));

setTimeout(async () => {
  console.log("initMethod: start");
  await service.initMethod()
  console.log("initMethod: end");
}, 1000);

Note: Using JavaScript private class features for the Snippet but I'm sure you can translate it to Typescript.

Phil
  • 157,677
  • 23
  • 242
  • 245
  • Similar to @Bertrand's answer, this will work (thank you!), but I'm hoping to avoid expecting the caller to await something before reading another value. I could see another developer just going directly for the value. – José Alvarado Torre Feb 16 '23 at 01:19
  • Isn't that why you make it private? You also do not need to await `initMethod` if that was your concern – Phil Feb 16 '23 at 01:20
  • `private` protects against outside callers, but it doesn't protect against callers within the same class. – José Alvarado Torre Feb 16 '23 at 01:28
  • 1
    @JoséAlvaradoTorre not sure I'm following you. That would be an issue no matter what you do. It's also the same issue in both the other answers (so not sure why only this one got a downvote) – Phil Feb 16 '23 at 01:30
  • I upvoted you. I disagree. Mulan's answer forces you to do the await before reading a value. That's one more bug that's not possible. – José Alvarado Torre Feb 16 '23 at 01:34
  • @JoséAlvaradoTorre there's a big difference with the code in your question, that being the `initMethod` doesn't resolve with any value. If you wanted `x` hidden behind a promise you should have made that clear – Phil Feb 16 '23 at 01:38
  • Mulan's solution doesn't require that I change initMethod's signature. However, it provides encapsulation. Encapsulation isn't a requirement, so I didn't specify it, but it significantly raises code quality. It prevents the bug I mentioned in an earlier comment. – José Alvarado Torre Feb 16 '23 at 02:27