Thursday, 8 September 2022

CFML: speaking of application scope proxies

G'day:

OK so you probably weren't talking about application scope proxies, but I was in my previous article: CFML: looking at how CFWheels messes up a loop. In that article I look at some very uncontrolled (and buggy: hence the article) application-scope access. And I made the observation that one should never access the application scope in one's application code, other than via a proxy.

This got me thinking. How hard is it actually to write one of these proxies that handles the locking for you, keeps yer code testable, and minimises the chance of having shared scope errors? All whilst minimising locking boilerplace and stuff.

Turns out it's pretty simple to get a proof of concept together (code on Github @ ApplicationScopeProxy.cfc / ApplicationScopeProxyTest.cfc):

component {

    public function init(struct applicationScope) {
        variables.applicationScope = arguments.applicationScope
    }

    public function set(required string key, required any value) {
        lock scope="application" type="exclusive" timeout=5 throwontimeout=true {
            "variables.applicationScope.#key#" = value
        }
    }

    public any function get(required string key) {
        lock scope="application" type="readonly" timeout=5 throwontimeout=true {
            if (isDefined("variables.applicationScope.#key#")) {
                return structGet("variables.applicationScope.#key#")
            }
            throw(type="ApplicationScopeProxy.KeyNotFoundException", message="Key [#key#] not found in application scope");
        }
    }

    public void function withReadOnlyLock(required function task) {
        lock scope="application" type="readonly" timeout=5 throwontimeout=true {
            task()
        }
    }

    public void function withExclusiveLock(required function task) {
        lock scope="application" type="exclusive" timeout=5 throwontimeout=true {
            task()
        }
    }
}

And it's got superficial first-pass red/green tests:

import testbox.system.BaseSpec
import cfml.cfmlLanguage.scopes.ApplicationScopeProxy

component extends=BaseSpec {

    function run() {
        describe("Tests of ApplicationScopeProxy", () => {
            describe("Tests for the set method", () => {
                it("sets a variable", () => {
                    testApp = {}
                    proxy = new ApplicationScopeProxy(testApp)
                    proxy.set("testKey", "TEST_VALUE")

                    expect(testApp).toHaveKey("testKey")
                    expect(testApp.testKey).toBe("TEST_VALUE")
                })

                it("sets a variable with a deep path", () => {
                    testApp = {}
                    proxy = new ApplicationScopeProxy(testApp)
                    proxy.set("one.two.three", "TEST_VALUE")

                    expect(testApp).toHaveKey("one")
                    expect(testApp.one.two.three).toBe("TEST_VALUE")
                })
            })

            describe("Tests for the get method", () => {
                it("gets a variable", () => {
                    testApp = {testKey = "TEST_VALUE"}
                    proxy = new ApplicationScopeProxy(testApp)
                    result = proxy.get("testKey")

                    expect(result).toBe("TEST_VALUE")
                })

                it("gets a variable with a deep path", () => {
                    testApp = {one={two={three = "TEST_VALUE"}}}
                    proxy = new ApplicationScopeProxy(testApp)
                    result = proxy.get("one.two.three", "TEST_VALUE")

                    expect(result).toBe("TEST_VALUE")
                })

                it("throws an exeption if the key is not found", () => {
                    testApp = {}
                    proxy = new ApplicationScopeProxy(testApp)

                    expect(() => {
                        proxy.get("one.two.three", "TEST_VALUE")
                    }).toThrow(
                        type = "ApplicationScopeProxy.KeyNotFoundException",
                        regex = "Key \[one\.two\.three\] not found in application scope"
                    )
                })
            })

            describe("Tests for the withReadOnlyLock method", () => {
                it("waits until it can get a lock before continuing", () => {
                    proxy = new ApplicationScopeProxy({})

                    telemetry = ["test start"]
                    t1 = runAsync(() => {
                        telemetry.append("t1 started")
                        lock scope="application" type="exclusive" timeout=5 throwontimeout=true {
                            sleep(1000)
                        }
                        telemetry.append("t1 ended")
                    })
                    t2 = runAsync(() => {
                        telemetry.append("t2 started")

                        proxy.withReadOnlyLock(() => {
                            telemetry.append("withReadOnlyLock ran")
                        })

                        telemetry.append("t2 ended")
                    })
                    t1.get()
                    t2.get()

                    lockTelemetry = telemetry.find("withReadOnlyLock ran")
                    t1EndTelemetry = telemetry.find("t1 ended")
                    expect(lockTelemetry).toBeGt(t1EndTelemetry, serializeJson(telemetry))
                })
            })

            describe("Tests for the withExclusiveLock method", () => {
                it("waits until it can get a lock before continuing", () => {
                    proxy = new ApplicationScopeProxy({})

                    telemetry = ["test start"]
                    t1 = runAsync(() => {
                        telemetry.append("t1 started")
                        lock scope="application" type="readlonly" timeout=5 throwontimeout=true {
                            sleep(1000)
                        }
                        telemetry.append("t1 ended")
                    })
                    t2 = runAsync(() => {
                        telemetry.append("t2 started")

                        proxy.withExclusiveLock(() => {
                            telemetry.append("withExclusiveLock ran")
                        })

                        telemetry.append("t2 ended")
                    })
                    t1.get()
                    t2.get()

                    lockTelemetry = telemetry.find("withExclusiveLock ran")
                    t1EndTelemetry = telemetry.find("t1 ended")
                    expect(lockTelemetry).toBeGt(t1EndTelemetry, serializeJson(telemetry))
                })
            })
        })
    }
}

The tests kinda summarise its usage, but there's four methods (five including constructor):

  • Constructor: pass in the scope from the application-boot code (from the DI config or just in onApplicationStart etc). This way even the proxy is uncoupled from the scope. It also aids testing, which you'll've noticed in the tests.
  • set - sets a value in the scope at the location of the specified key. The key can be a dotted-path, and that will be unpacked into substructs. Is run in an exclusive lock.
  • get - as above, but gets it back out again. Is run in a read-only lock.
  • withReadOnlyLock - to avoid race conditions like in the CFWheels code, this takes a block of code and runs it all in a read-only lock. Good for multiple accesses to the application scope that rely on the state not being changed between each access.
  • withExclusiveLock - same as above, except with an exclusive lock. This would handly situations where code is reading and writing to the scope, and needs to be run as an atom before other accesses try to act on the data themselves.

This is just a proof of concept I knocked together in about 1hr without giving too much thought to it (the best way to write code to present to the public as if I know what I'm doing ;-)). There is some room for improvement, obvs:

  • It's not for the methods themselves to set the timeout / throwontimeout values on the locks. I just set those for the sake of expediency. A real-world implementation would likely pass those with each call, and probably optionally fall back to values set by init, or just the default for lock (if there is one? I think maybe not, for timeout).
  • If the returned value is a complex object, then it'll still be prone to race conditions as it'll just be a reference to the underlying shared value. So one would either need to advise to use duplicate when fetching the value, or make it an option on the get, or have a getCopy method too.
  • Having methods like exists, delete, etc would be handy too.
  • Maybe some mechanism to pass in a callback that receives variables.applicationScope as an argument, to do more complex stuff, and the implementation of that just ensures the lock is made?

There'd be plenty of options. The good thing with TDD and incremental delivery, is that one can just deliver what's needed now, and worry about the rest later.

What CFWheels needs now is that withExclusiveLock method, I think…

Righto.

--
Adam