CiviCRM dependency injection worked example using Civi::lock()

Tags: 

This documents a journey of discovery, in case it's of interest to any other devs.

I have a process that affects a large number of records in the pattern: load 'em all, change 'em all, save 'em all.

I don't want 2 simultaneous users jumping in on each other - e.g. if user 1 loads, then user 2 loads, then user 1 saves, then user 2 saves, user1's changes are lost. I was interested in using MySQL/MariaDB's GET_LOCK() method to help with this.

But wait, there's a Civi::lockManager()->acquire('my.special.lockname') Maybe that's a better way to do it?

I tried using it and soon found an error that my.special.lockname didn't work: I needed to register a pattern for it that pointed to a factory method that would generate a suitable lock object.

I initially followed the code path for the statement above and found this:

  1. How does lockManager()->acquire() work?
  2. The docblock says it returns a \Civi\Core\Lock\LockInterface
  3. That tells me the method signatures I can use with a lock object, great.
  4. The lock manager acquire method calls its own create() method to get a lock,
  5. The create() method calls its getFactory() method which looks up the given name against a set of configured preg_match patterns to find the factory.
  6. Then Civi\Core\Resolver is used to resolve the factory identifier to a callable, which is what must return the lock object implementing LockInterface.
  7. Finally, the lock manager calls acquire($name) on the lock and returns the lock object.

This left me with more questions than answers: it's a massive tangle of redirections until step 7.

  • How do we know what we actually end up with?
  • How do patterns to factory IDs get registered?
  • How do factory IDs get resolved to callable factories?

This took me back a step: what does  Civi::lockManager()  do? Following that through I found that it calls Container::getBootService('lockManager') which simply returns whatever is in Civi::$statics['\Civi\Core\Container', 'boot', 'lockManager']

So where is that set?

I saw a boot() method in the Container class, I bet that's it. Sure enough it is. Here we have hard coded defaults in a createLockManager() method that register four patterns → factory IDs. Most of these return factory IDs like ['CRM_Core_Lock', 'createScopedLock']  unless some constants are defined with string names.

Turns out the resolver is an identity function if the factory ID is already an array (or an object), i.e. it does nothing in this case. That array is  the callable factory.

Here's what the boot/configure process looks like

Diagram showing code path for configuring the lockmanager

Here's what the usage Civi::lock()->acquire() call looks like

Diagram showing codepath of LockManager::acquire()

So far

Wow, what a long code path for a seemingly simple task - all I wanted was CRM_Core_DAO::executeQuery('GET_LOCK(...)') ! But you can see this comlexity has given us a lot of flexibility in how things work:

  • We can have different locking strategies for different lock names - so we might not always be using a CRM_Core_Lock object.
  • We can reuse locking strategies for different lock names.
  • Four levels of granularity are provided/used (cache, data, worker and worker.mailing.send), though I'm not super clear on when you'd choose one or the other, and 3 of them all do the same thing anyway, by default
  • You can implement and register your own locking implementation for a particular lock name pattern.
  • If that's not enough, we can potentially replace the LockManager class.

Although it does mean that we must be specific about how we want to achieve our lock by choosing a name that matches a pattern in code.

Conclusion

For my task, in my head, I want to lock up some data, so rightly or wrongly, I'm choosing  Civi::lockManager()->acquire('data.my.special.lockname')

 

Add new comment