Changing a "constant" type and triggering recompilation

I’m writing a calculation engine that is going to run as a service. This is for an industrial system, where data sources (tags) have units. Because many of these units have offsests (Fahrenheit, PSI-gauge) I can’t use DynamicQuantities.jl, instead, I’m stuck using Unitful.jl. In order to have type-stability, I need a strongly-typed mixed container like a NamedTuple as a constant.

const TAG_UNITS = (
        tag1 = u"mcfd",
        tag2 = u"ozp",
        tag3 = u"°F"
)

These units are present in a CSV file, so I can even have the constant loaded from the information in the CSV file. So far so good. However, the units of these tags may (rarely) change. It would be nice if I could somehow change that constant and recompile all the code that uses it without having to restart the Julia process.

I’m currently considering using a dynamic module pattern. Something like

function unit_conversions(modulefile::String)
    m = Module(:UnitConversions)
    Base.include(m, modulefile)
    return m
end

const UNIT_CONVERSIONS = Ref(unit_conversions("unit_conversions.jl"))

function update_units()
    UNIT_CONVERSIONS[] = unit_conversions("unit_conversions.jl")
end

The actual module will contain the constant as well as all tag unit conversions operations. So all unit conversions operations would use a call like

si_tag_dict = UNIT_CONVERSIONS[].to_si_units(raw_tag_dict)

I know that this can result in “world-age” issues if UNIT_CONVERSIONS[] changes inside a function that references it, so I’m either going to have Python call this function from within a FastAPI app, or have it called as an explicit endpoint in a REST API in an HTTP.jl app.

I feel like this is a bit kludgy, but it feels like the least kludgy way to do this. I’m wondering if this option is sane, and whether or not there’s a more canonical way to have this behaviour.

I mean… don’t use a constant if it’s not constant? Sure, that will incur type instabilities, but so will calling out to python/HTTP/whatever. Not only will that have type instabilities on the Julia side, now you have two problems — you gotta maintain two systems.

It just seems you are digging a deeper and deeper rabbit hole here.

1 Like

The problem is that it usually is constant, and that information is required repeatedly at a pretty low level but it might change once or twice in a year. The HTTP/Python callout happens at a very high level so I’m not worried about that. The way your response is framed isn’t very helpful. I guess the more canonical way might be is to use the function barrier technique and include the units object as a high-level argument in a function that only gets called once? Is that what you’re suggesting?

My point is more that there’s no free lunch here. If you want it to be able to change types, then that’s a type instability no matter how you cut it. The program needs to be able to dynamically change its behavior. Type instabilities are one way to handle that dynamic change — and it’s a feature built into Julia itself.

You could alternatively use a function definition for this — something as simple as units() = (tag1 = u"mcfd",tag2 = u"ozp", tag3 = u"°F") — but yes, now you’ll need to handle that dynamicism with @invokelatest. Which, really, isn’t all that different from just doing the straightforward thing with a function barrier.

Or you could encode your units in a way that doesn’t use the type system. Yes, that means giving up on Unitful, but it depends on how much Unitful is “paying you back” in terms of the functionality you gain.

2 Likes

I’m fully aware that there’s a type instability any way you cut it. What I’m trying to do is minimize the performance impact of this necessary type instability. For example, lets say I have something like this

TAG_LIST = ("tag1", "tag2", "tag3")

function units_from_csv(tag_csv_file)
    #... (reads csv file into tag_dict with tags as keys and string units as values)
    tag_pair(tag::String) = Symbol(tag)=>uparse(tag_dict[tag])
    unit_pairs = map( tag_pair, for tag in TAG_LIST)
    return (; unit_pairs...)
end
    
TAG_UNITS = units_from_csv("tag_units.csv")

function update_units_from_csv(tag_csv_file)
    global TAG_UNITS
    TAG_UNITS = units_from_csv("tag_units.csv")
end

function convert_units(tag_dict, units)
   #... (loop through the units in "units" and produce si_tag_dict::Dict{String,Float64})
   return si_tag_dict
end

convert_units(tag_dict) = convert_units(tag_dict, TAG_UNITS)

With this, I’m guessing that if I call convert_units(tag_dict) it will use dynamic dispatch once, but since we’re calling on a constant value, it won’t change while the function is running, so everything inside convert_units(tag_dict, TAG_UNITS) will be type-stable, so I only pay the price of type-instability once. Correct?

I don’t know the structure of your program, but I assume it has a main loop.

What you could do is

  1. make your global constant into a function which returns the current units.

  2. wrap the main loop in an outer function which checks for an updated value for the units, and @evals a new version of the global constant function that contains the new values. It would then call invokelatest(main_loop).

  3. periodically break out of the main loop to go back up to the wrapper function.