Skip to content

When RtdPublisher returns cached objects they are getting mixed up between topics #141

Description

@andyvandy

Hi Steven,

When returning a python object from an RtdPublisher without a converter, the automatic cache key generation ends up using a shared cache for all publishers publishing that object type. This results in values from different topics appearing in the wrong cells.

Details

We call FromPyObj() in publish which eventually calls ReturnToCache()

xlValue = converter
? (*converter)(ptr)
: FromPyObj()(ptr);

Since the caching isn't being called from a cell, xlfCaller fails to return an address in CallerInfo() which causes us to fall back to "Unknown" in writeAddressImpl. The value is then cached and the cache key is broadcast.

CallerInfo::CallerInfo()
{
callExcelRaw(xlfCaller, &_address);
callExcelRaw(xlSheetNm, &_sheetName,
_address.isType(ExcelType::RangeRef) ? &_address : &theA1Ref);
}

default: // Other callers
constexpr wchar_t nonWorksheetCaller[] = L"Unknown";

When rtdpeek or rtdsubscribe get a value like 心[nknownExample,C, they will look to the cache and retrieve a value from the cache and then replace it back into their respective cellcache but with many publishers, it's likely the value was intended for another topic.

Repro

The following formulas can be copied into and new workbook with the second row dragged down a handful of times to increase the load.

blank|=ROW(A1)&";"&COLUMN(A1) |=RtdExample(B1)|=xloAttr(C1,"value")|=COUNTA(UNIQUE(D:D))|=COUNTA(UNIQUE(B:B))
blank|=ROW(A2)&";"&COLUMN(A2) |=RtdExample(B2)|=xloAttr(C2,"value")|
import asyncio
from dataclasses import dataclass
import time
import xloil as xlo

@dataclass
class Example:
    value: str

if "_rtdServer" not in globals():
    # don't recreate on reload!
    _rtdServer = xlo.RtdServer()

class RtdPublisherExample(xlo.RtdPublisher):
    """lifted from https://xloil.readthedocs.io/en/stable/xlOil_Python/ExampleRTD.html"""
    def __init__(self, input_string:str):
        super().__init__()  # You *must* call this explicitly or the python binding library will crash
        self._python_object = Example(input_string)
        self._topic = input_string
        self._task = None
        
    def connect(self, num_subscribers):
        if self.done():
            async def run():
                try:
                    while True:
                        _rtdServer.publish(self.topic(),Example(self._topic + " " + str(time.time())))
                        await asyncio.sleep(0.01)
                        # await asyncio.sleep(1.1)
                except Exception as e:
                    _rtdServer.publish(self._url, e)
        
            self._task = xlo.get_event_loop().create_task(run())
            
    def disconnect(self, num_subscribers):
        if num_subscribers == 0:
            self.stop()
            return True # This publisher is no longer required: schedule it for destruction
            
    def stop(self):
        pass

    def done(self):
        return self._task is None or self._task.done()
        
    def topic(self):
        return self._topic


@xlo.func(local=False)
def RtdExample(input_string):
    if _rtdServer.peek(input_string) is None:
        publisher = RtdPublisherExample(input_string)
        _rtdServer.start(publisher)
    return _rtdServer.subscribe(input_string)      

Next Steps

I took a stab at addressing this by explicitly providing the topic as a cache key which resolved the collisions between topics but didn't fully resolve the issues.

PyRtd.cpp

FromPyObj < detail::ReturnToSpecifiedCache, true > (detail::ReturnToSpecifiedCache{topic})(ptr);

BasicTypes.h ( I also added a simple pyCacheAddByKey )

/// <summary>
/// Used with FromPyObj to return unknown objects as a cache ref using a specific key rather than relying on CallerInfo
/// </summary>
struct ReturnToSpecifiedCache
{
    const wchar_t* key;

    // Constructor requires key to be provided and Delete default constructor to prevent uninitialized usage
    explicit ReturnToSpecifiedCache(const wchar_t* k) : key(k) {}

    ReturnToSpecifiedCache() = delete;

    template <class TAlloc>
    auto operator()(PyObject* obj, const TAlloc& stringAllocator)
    {
        return ExcelObj(BasicPString<wchar_t, TAlloc>(pyCacheAddByKey(PyBorrow(obj),key).asStringView(), stringAllocator));
    }
};

However I see an issue with this when the excel calc frequency is less than the RTD publisher frequency. When multiple values are cached to the same cellcache we increment the cache counter.

RTD peek will fetch the cache key (eg 心[topicABC,C ) on line 328 but not the cached value. If the calc cycle has ticked forward and the cache was just cleared then there may not be enough values when we use the cache key to retrieve the python object.

py::object peek(const wchar_t* topic, IPyFromExcel* converter = nullptr)
{
shared_ptr<const ExcelObj> value;
{
py::gil_scoped_release releaseGil;
value = impl().peek(topic);
}
if (!value)
return py::none();
return PySteal<>(converter
? (*converter)(*value)
: PyFromAny()(*value));
}

I think we can address this by not allowing multiple separate auto cached values for RTDPublishers and to limit the cache size to 1. iterables of cacheables would need be cached directly rather than be split out.

I recall previously you mentioned you had thoughts of a grander overhaul of caching. I'd be very interested to hear your thoughts on how you'd like to approach this. In the mean time I'll attempt getting a POC PR assembled.


As always hope all is well and appreciate you sharing this library!

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions