Merge lp:~seif/zeitgeist/add_cache_for_get_events into lp:zeitgeist/0.1

Proposed by Seif Lotfy
Status: Superseded
Proposed branch: lp:~seif/zeitgeist/add_cache_for_get_events
Merge into: lp:zeitgeist/0.1
Diff against target: 407 lines (+297/-8)
6 files modified
_zeitgeist/Makefile.am (+2/-1)
_zeitgeist/engine/__init__.py (+7/-0)
_zeitgeist/engine/main.py (+64/-6)
_zeitgeist/lrucache.py (+125/-0)
test/lrucache_test.py (+98/-0)
zeitgeist/client.py (+1/-1)
To merge this branch: bzr merge lp:~seif/zeitgeist/add_cache_for_get_events
Reviewer Review Type Date Requested Status
Mikkel Kamstrup Erlandsen Needs Information
Review via email: mp+42327@code.launchpad.net

This proposal has been superseded by a proposal from 2011-01-12.

Description of the change

I added a cache to reduce the calls on get_events
basically i look at separate the cached ids from the ids handed to get_events
then i call the sql db to give me all uncached events then i sort them all and voila
the speed improvement is very nice

old DEBUG:zeitgeist.engine:Got 34 events in 0.001895s
new DEBUG:zeitgeist.engine:Got 34 events in 0.000248s

To post a comment you must log in.
Revision history for this message
Mikkel Kamstrup Erlandsen (kamstrup) wrote :

So we go from 18ms to 2ms. That's a speedup of a factor of 9, quite nice :-)

As discussed on IRC we need to limit the cache somehow of course. The old LRU cache comes to mind. Also, we need to be relatively cautious here running the danger of ballooning our memory consumption.

I'd like to see some more elaborate speed tests (Markus' multicolor diagram ftw!) as well using a bounded cache implementation of some sort. Also an assessment of the memory impact of this.

And please remember to revive the unit tests for the LRU cache if we do choose to bring it back :-)

I really like where this is going :-)

review: Needs Fixing
1644. By Seif Lotfy

add rewritten LRU cache with test cases

Revision history for this message
Seif Lotfy (seif) wrote :

I rewrote the LRU cache since the old one gave me around 0.6 ms
and the new one 0.3 ms

Revision history for this message
Mikkel Kamstrup Erlandsen (kamstrup) wrote :

Something is fishy in LRUCache.__getitem__ and __setitem__. You do a linear search for the id in self._id_seq by using the 'in' operator, and another linear search by using 'remove' afaik. So they become O(2N) in the worst case which is not a very desirable property for a cache ;-)

Unless of course the deque is indexed internally - but that would surprise me. In this case my comments are moot.

Revision history for this message
Seif Lotfy (seif) wrote :

I am not using deque :P
But I can push it

On Wed, Dec 1, 2010 at 4:46 PM, Mikkel Kamstrup Erlandsen <
<email address hidden>> wrote:

> Something is fishy in LRUCache.__getitem__ and __setitem__. You do a linear
> search for the id in self._id_seq by using the 'in' operator, and another
> linear search by using 'remove' afaik. So they become O(2N) in the worst
> case which is not a very desirable property for a cache ;-)
>
> Unless of course the deque is indexed internally - but that would surprise
> me. In this case my comments are moot.
> --
>
> https://code.launchpad.net/~seif/zeitgeist/add_cache_for_get_events/+merge/42327
> You are the owner of lp:~seif/zeitgeist/add_cache_for_get_events.
>

--
This is me doing some advertisement for my blog http://seilo.geekyogre.com

Revision history for this message
Seif Lotfy (seif) wrote :

scratch that i did not understand your comment properly...
I reduced the "in" statement by just using self._map.has_key(key)

On Wed, Dec 1, 2010 at 4:48 PM, Seif Lotfy <email address hidden> wrote:

> I am not using deque :P
> But I can push it
>
>
> On Wed, Dec 1, 2010 at 4:46 PM, Mikkel Kamstrup Erlandsen <
> <email address hidden>> wrote:
>
>> Something is fishy in LRUCache.__getitem__ and __setitem__. You do a
>> linear search for the id in self._id_seq by using the 'in' operator, and
>> another linear search by using 'remove' afaik. So they become O(2N) in the
>> worst case which is not a very desirable property for a cache ;-)
>>
>> Unless of course the deque is indexed internally - but that would surprise
>> me. In this case my comments are moot.
>> --
>>
>> https://code.launchpad.net/~seif/zeitgeist/add_cache_for_get_events/+merge/42327
>> You are the owner of lp:~seif/zeitgeist/add_cache_for_get_events.
>>
>
>
>
> --
> This is me doing some advertisement for my blog http://seilo.geekyogre.com
>

--
This is me doing some advertisement for my blog http://seilo.geekyogre.com

1645. By Seif Lotfy

reduced the 'in' statement by replacing it with self._map.has_key(key)

1646. By Seif Lotfy

made use of deque

1647. By Seif Lotfy

no need to use deque so replace popleft with del at position 0

Revision history for this message
Seif Lotfy (seif) wrote :

Guys I reworked it.
If I recall properly I think Markus said its ok like this, since its not a linear search. I am not sure but please reconsider since it really brings a big improvement to the table.

Revision history for this message
Seif Lotfy (seif) wrote :

OK I don't think Markus said its not a linear search.
I also asked in the python channel and

----
<seiflotfy> is it a linear search to find the item i want to remove
<papna> seiflotfy: Yes.
<seiflotfy> whoa
<papna> seiflotfy: Also, don't use list.remove
<papna> seiflotfy: Decribe your data.
<seiflotfy> i am implementing an lru cache
<seiflotfy> basically a list of ids in a list
<seiflotfy> then i remove a key when its used
<seiflotfy> and append it to the end of the list
<nosklo> seiflotfy: dict
<seiflotfy> yeah
<seiflotfy> i will sill have to go through the dict
<seiflotfy> ot change the value of each key
<seiflotfy> which is still a linear seach AFAIK
<papna> seiflotfy: Yeah, a list isn't appropriate for that.
* zobbo (~ian@88.211.55.77) has joined #python
<papna> seiflotfy: One approach is to keep a linked list and a dict.
<seiflotfy> papna, i did
<seiflotfy> but the list approach was faster
<papna> seiflotfy:
http://bitbucket.org/mikegraham/remember/src/af922eac912c/remember/dicts.py#cl-138
<seiflotfy> papna, i can show u 2 code smaples
<papna> seiflotfy: For realistic n, sure.
-----
I implemented his proposal. And in real life it my list solution is 2x
faster at mimimum.

On Tue, Dec 7, 2010 at 3:37 PM, Seif Lotfy <email address hidden> wrote:

> Guys I reworked it.
> If I recall properly I think Markus said its ok like this, since its not a
> linear search. I am not sure but please reconsider since it really brings a
> big improvement to the table.
> --
>
> https://code.launchpad.net/~seif/zeitgeist/add_cache_for_get_events/+merge/42327
> You are the owner of lp:~seif/zeitgeist/add_cache_for_get_events.
>

--
This is me doing some advertisement for my blog http://seilo.geekyogre.com

Revision history for this message
Mikkel Kamstrup Erlandsen (kamstrup) wrote :

In lrucache.py:
__delitem__ must also update id_seq

Also can LRUCache maybe log a warning if you create it with maxsize > 1000 or something. This implementation scales very badly, although as you say, it may well be faster for small caches.

Lastly, have you actually benchmarked this with 1000 items in the cache? Because my guess says that that should be around the tipping point where the linear searches starts to make an impact...

Maybe reduce the cache size to 500 just to feel a little safer?

review: Needs Fixing
1648. By Seif Lotfy

added old lru cache

Revision history for this message
Seif Lotfy (seif) wrote :

I did some testing an with a cache of 700 events my solution starts becoming
slower thus I removed it for a more consitent lru cache (the old one). This
removes 50% off the get_events

On Tue, Dec 7, 2010 at 8:01 PM, Mikkel Kamstrup Erlandsen <
<email address hidden>> wrote:

> Review: Needs Fixing
> In lrucache.py:
> __delitem__ must also update id_seq
>
> Also can LRUCache maybe log a warning if you create it with maxsize > 1000
> or something. This implementation scales very badly, although as you say, it
> may well be faster for small caches.
>
> Lastly, have you actually benchmarked this with 1000 items in the cache?
> Because my guess says that that should be around the tipping point where the
> linear searches starts to make an impact...
>
> Maybe reduce the cache size to 500 just to feel a little safer?
> --
>
> https://code.launchpad.net/~seif/zeitgeist/add_cache_for_get_events/+merge/42327
> You are the owner of lp:~seif/zeitgeist/add_cache_for_get_events.
>

--
This is me doing some advertisement for my blog http://seilo.geekyogre.com

Revision history for this message
Markus Korn (thekorn) wrote :

I don't think we want to cache events at all.
So far we have a simple policy, we are only caching objects we completely understand, where we know their size and how big (memory wise) they are (e.g mimetypes, interpretation, manifestation). We are not caching uris (because there can be an indefinite number of them), payload (because payload is unique for each event, and has an undefined max size), and so on.
For me there are three reasons why I don't want to cache events:
 * we can't control the in-memory size of this cache, something like LRUCache(50) does not mean it's using less memory than LRUCache(500), simply because if only one event with a 5 GB (*) payload is in one cache the memory usage of the zg daemon will be HUGE.
 * by adding an event cache we are indirectly caching objects like payload, uri, etc. we don't want to cache, for above mentioned reasons.
 * I don't think we are able to find a performant Cache implementation for all possible clients. Just imaging we have two clients accessing the daemon, one which only gets a few events at a time (e.g. unity) and a one which is frequently getting *all* events (something like the GAJ histogram [not sure if this is still the case, but you should get my point ;)]) While any cache would do a good job for unity, only one GetEvents in GAJ will destroy/shuffle the complete cache in a way that the performance win for unity gets destroyed.

So I suggest two alternative solutions:
 * encourage clients to maintain their own caches, and use FindEventIds() + GetEvents[(all ids whih are not in client's cache)] as much as possible. If this turns out to have performance issues, we should improve that
 * if you really want a distributed cache, write an event-cache-daemon which only has one public API method "GetEvents" and which works like a cached proxy, similar to memcached

(*) random high number

Revision history for this message
Seif Lotfy (seif) wrote :

It is easy to control the size of the cache if we don't cache events with a
payload.
Usually most applications are interested in the latest events. Ofcourse AJ
breaks that but still, u might be running 3 or 4 apps that are usually
interested in the latest activities.
AFAIK zeitgeist does not really free uri memory at all. So a cache is not an
issue here.

On Wed, Dec 8, 2010 at 9:24 AM, Markus Korn <email address hidden> wrote:

> I don't think we want to cache events at all.
> So far we have a simple policy, we are only caching objects we completely
> understand, where we know their size and how big (memory wise) they are (e.g
> mimetypes, interpretation, manifestation). We are not caching uris (because
> there can be an indefinite number of them), payload (because payload is
> unique for each event, and has an undefined max size), and so on.
> For me there are three reasons why I don't want to cache events:
> * we can't control the in-memory size of this cache, something like
> LRUCache(50) does not mean it's using less memory than LRUCache(500), simply
> because if only one event with a 5 GB (*) payload is in one cache the memory
> usage of the zg daemon will be HUGE.
> * by adding an event cache we are indirectly caching objects like payload,
> uri, etc. we don't want to cache, for above mentioned reasons.
> * I don't think we are able to find a performant Cache implementation for
> all possible clients. Just imaging we have two clients accessing the daemon,
> one which only gets a few events at a time (e.g. unity) and a one which is
> frequently getting *all* events (something like the GAJ histogram [not sure
> if this is still the case, but you should get my point ;)]) While any cache
> would do a good job for unity, only one GetEvents in GAJ will
> destroy/shuffle the complete cache in a way that the performance win for
> unity gets destroyed.
>
> So I suggest two alternative solutions:
> * encourage clients to maintain their own caches, and use FindEventIds() +
> GetEvents[(all ids whih are not in client's cache)] as much as possible. If
> this turns out to have performance issues, we should improve that
> * if you really want a distributed cache, write an event-cache-daemon
> which only has one public API method "GetEvents" and which works like a
> cached proxy, similar to memcached
>
>
> (*) random high number
> --
>
> https://code.launchpad.net/~seif/zeitgeist/add_cache_for_get_events/+merge/42327
> You are the owner of lp:~seif/zeitgeist/add_cache_for_get_events.
>

--
This is me doing some advertisement for my blog http://seilo.geekyogre.com

Revision history for this message
Markus Korn (thekorn) wrote :

A quick & dirty graph comparing lp:zeitgeist with this branch is at [0]

I've benchmarked engine.get_events() on an activity log with 50k events for different batch sizes (means length of arguments to get_events())

I'm running each query two times, the first time the cache is cold (marked by "_C") and the second time the cache is HOT ("_H")

Results:
 * there is no difference between each run for trunk, which is expected
 * cold cache (getting an event the first time) is always slower in this branch
 * running the queries with hot cache is fast for small batch sizes
 * for huge batch sizes no caching is always faster

[0] https://dl.dropbox.com/u/174479/cache_benchmarks.svg

Revision history for this message
Seif Lotfy (seif) wrote :

Awesome
But what is a batch?

On Wed, Dec 8, 2010 at 12:33 PM, Markus Korn <email address hidden> wrote:

> A quick & dirty graph comparing lp:zeitgeist with this branch is at [0]
>
> I've benchmarked engine.get_events() on an activity log with 50k events for
> different batch sizes (means length of arguments to get_events())
>
> I'm running each query two times, the first time the cache is cold (marked
> by "_C") and the second time the cache is HOT ("_H")
>
> Results:
> * there is no difference between each run for trunk, which is expected
> * cold cache (getting an event the first time) is always slower in this
> branch
> * running the queries with hot cache is fast for small batch sizes
> * for huge batch sizes no caching is always faster
>
> [0] https://dl.dropbox.com/u/174479/cache_benchmarks.svg
> --
>
> https://code.launchpad.net/~seif/zeitgeist/add_cache_for_get_events/+merge/42327
> You are the owner of lp:~seif/zeitgeist/add_cache_for_get_events.
>

--
This is me doing some advertisement for my blog http://seilo.geekyogre.com

Revision history for this message
Markus Korn (thekorn) wrote :

batchsize is number of ids in the get_events() argument,
so,
 get_events([1, 2]) -> batchsize == 2
 get_events(range(80)) -> batchsize == 80

Revision history for this message
Seif Lotfy (seif) wrote :

Thanks for the detailed test. For that I have a solution that I know some of
you might disagree with but I would like to put it out for discussion:
We can create a filter in the cache module to not accept events with
payloads != null
Also we can avoid jumping to look into the cache for batchsizes that exceed
"n" ids.
This is just brainstorming here, because I see a small benefit of LRU cache
for small batches.

On Wed, Dec 8, 2010 at 12:46 PM, Markus Korn <email address hidden> wrote:

> batchsize is number of ids in the get_events() argument,
> so,
> get_events([1, 2]) -> batchsize == 2
> get_events(range(80)) -> batchsize == 80
> --
>
> https://code.launchpad.net/~seif/zeitgeist/add_cache_for_get_events/+merge/42327
> You are the owner of lp:~seif/zeitgeist/add_cache_for_get_events.
>

--
This is me doing some advertisement for my blog http://seilo.geekyogre.com

Revision history for this message
Seif Lotfy (seif) wrote :

I ran some test with bigger cache size (10k) and the change was noticeable that the cache gave a big improvement when Hot.
So I ran some tests and observed that if "cache size < batch size" slowdowns might occur.
My solution for this is to ignore "caching and using cache" for batch_sizes that exceed the cache_size. 1 big batch event can just destroy the cache. Thus using something like batch_size_tolerance = cache_size/2 would allow us to guarantee that one call won't mess up the cache and lead to slowdowns.

Revision history for this message
Seif Lotfy (seif) wrote :

Just updated the merge request with some batch_size_tolerance modification

1649. By Seif Lotfy

add batch_tolerance support

Revision history for this message
Seif Lotfy (seif) wrote :

Here is a csv file based on markus script (for some reason plotting doesnt work for me)
You can see the resuls are pretty good
basically unitl batch size 1000 the hot cache is always faster, then it is just like the noncached version in trunk

---
;50;500;950;1000;1050;2000;5000;10000
zeitgeist_C;0,002746;0,024526;0,046756;0,048572;0,051662;0,098936;0,245838;0,518534
zeitgeist_H;0,0023;0,023295;0,045963;0,04813;0,052496;0,097886;0,252786;0,493975
add_cache_for_get_events_C;0,003201;0,029415;0,054442;0,060075;0,05168;0,098671;0,24538;0,509424
add_cache_for_get_events_H;0,000868;0,00736;0,014856;0,013968;0,051253;0,09653;0,252904;0,485511
---

Revision history for this message
Mikkel Kamstrup Erlandsen (kamstrup) wrote :

I think Markus has a very good observation. If we are serving clients that request 10k events and other clients that request 10-100 events then It's really hard to get a good caching scheme. The big requests will just always blow the cache (and a cache size of 10k is a no-go by me).

Adding some logic on top to control when and how the cache is applied is a logical next step. It does however set off my internal alarm system. Unless very carefully tested and maintained it might very well end up slowing us down and giving clients unreliable response times because the cache logic is too hard to predict.

My gut tells me that if we too much logic for cache management then we are doing it wrong. It should be simple.

Regarding the cache size, I'm really only worried about the payloads. If we have a cache size of 500 and URIs have a typical length of 100 letters then we'll use 50.000 bytes, ~50kb, for the URIs. So wrt the payloads let's assume that someone stored 100k XML blobs with all events. That gives us 500*100k ~= 50mb. That is too much. Either not caching events with payloads, or fetching payloads lazily could fix this though.

Revision history for this message
Markus Korn (thekorn) wrote :

Btw, there is another way to blow up the cache: having events with thousands of subjects, so we should limit the number of subjects for cached events too.
Also, please do not hardcode the limits
  ([...]34 + if len(ids) > 1000:[...])
please use a modul-wide variable, or maybe eent set the limits in the config object.

Revision history for this message
Seif Lotfy (seif) wrote :

Guys I added a plot of the current state of my "logic" for the cache
http://dl.dropbox.com/u/7162902/cache_plot.svg

1650. By Seif Lotfy

added ZEITGEIST_CACHE_SIZE env variable and a check on payloads

Revision history for this message
Mikkel Kamstrup Erlandsen (kamstrup) wrote :

Can you please add a code comment explaining why we don't cache events with payloads, it would seem arbitrary to the uninitiated.

Other than that it looks good to me. If Markus approves feel free to merge.

review: Approve
Revision history for this message
Mikkel Kamstrup Erlandsen (kamstrup) wrote :

Oh, btw. I couldn't see from the diff and I don't have time to check the full code: Is the cache also used in FindEvents()? That would be where we'd get the biggest win I think.

Revision history for this message
Seif Lotfy (seif) wrote :

It is not used in find_events yet but I have a hint of a solution to make use of it :)

1651. By Seif Lotfy

added comment on why we dont cache events with payloads
added to lrucache.py to Makefile.am

1652. By Seif Lotfy

fix statement in get_events where looking if the event has a payload

1653. By Seif Lotfy

replaced lrucache

Revision history for this message
Mikkel Kamstrup Erlandsen (kamstrup) wrote :

I see you replaced the LRUCache implementation? Is this your own implementation, it doesn't look like your code style. I'm worried about licensing issues because you removed the LGPL header.

That said it makes a lot of sense to leverage the native collections module - so in principle I'm +1 for this approach.

review: Needs Information
Revision history for this message
Seif Lotfy (seif) wrote :

Hey,
Ofcourse its not my code :P
http://pypi.python.org/pypi/lrucache/0.2
<http://pypi.python.org/pypi/lrucache/0.2>AFAIK its AFL 1.2 License. I did
not see it till now so this might be an issue.
http://en.wikipedia.org/wiki/Academic_Free_License

On Sun, Dec 12, 2010 at 8:04 AM, Mikkel Kamstrup Erlandsen <
<email address hidden>> wrote:

> Review: Needs Information
> I see you replaced the LRUCache implementation? Is this your own
> implementation, it doesn't look like your code style. I'm worried about
> licensing issues because you removed the LGPL header.
>
> That said it makes a lot of sense to leverage the native collections module
> - so in principle I'm +1 for this approach.
> --
>
> https://code.launchpad.net/~seif/zeitgeist/add_cache_for_get_events/+merge/42327
> You are the owner of lp:~seif/zeitgeist/add_cache_for_get_events.
>

--
This is me doing some advertisement for my blog http://seilo.geekyogre.com

Revision history for this message
Markus Korn (thekorn) wrote :

@Seif, what's the reason for using another cache implementation?

Revision history for this message
Seif Lotfy (seif) wrote :

@Markus
1) The previous LRU has something broken in _move_item_to_end for some
reason. (*)
2) It's faster

(*) This is the Error I get. It can be replicated by opening and closing GAJ
several times.
Traceback (most recent call last):
  File "/usr/lib/pymodules/python2.6/dbus/connection.py", line 576, in
msg_reply_handler
    reply_handler(*message.get_args_list(**get_args_opts))
  File "/home/seif/Projects/gnome-activity-journal/src/activity_widgets.py",
line 1303, in do_set
    self.set_items(objs)
  File "/home/seif/Projects/gnome-activity-journal/src/activity_widgets.py",
line 1310, in set_items
    box = CategoryBox(None, items, True, itemoff=4)
  File "/home/seif/Projects/gnome-activity-journal/src/activity_widgets.py",
line 315, in __init__
    + " " +
str((time.localtime(int(event_structs[0].event.timestamp)/1000).tm_hour)/8)
+ " " + str(category)
  File "/home/seif/Projects/gnome-activity-journal/src/store.py", line 108,
in __get__
    value = self.method(instance)
  File "/home/seif/Projects/gnome-activity-journal/src/store.py", line 125,
in event
    events = INTERFACE.GetEvents([self.id])
  File
"/home/seif/Projects/gnome-activity-journal/../zeitgeist/zeitgeist/client.py",
line 88, in _ProxyMethod
    return self._disconnection_safe(lambda:
  File
"/home/seif/Projects/gnome-activity-journal/../zeitgeist/zeitgeist/client.py",
line 68, in _disconnection_safe
    return meth()
  File
"/home/seif/Projects/gnome-activity-journal/../zeitgeist/zeitgeist/client.py",
line 89, in <lambda>
    getattr(self.__iface, name)(*args, **kwargs))
  File "/usr/lib/pymodules/python2.6/dbus/proxies.py", line 140, in __call__
    **keywords)
  File "/usr/lib/pymodules/python2.6/dbus/connection.py", line 620, in
call_blocking
    message, timeout)
dbus.exceptions.DBusException: org.freedesktop.DBus.Python.AttributeError:
Traceback (most recent call last):
  File "/usr/lib/pymodules/python2.6/dbus/service.py", line 702, in
_message_cb
    retval = candidate_method(self, *args, **keywords)
  File
"/home/seif/Projects/add_cache_for_get_events/zeitgeist/../_zeitgeist/engine/remote.py",
line 86, in GetEvents
    sender=sender))
  File
"/home/seif/Projects/add_cache_for_get_events/zeitgeist/../_zeitgeist/engine/main.py",
line 202, in get_events
    event = self._event_cache[id]
  File
"/home/seif/Projects/add_cache_for_get_events/zeitgeist/../_zeitgeist/lrucache.py",
line 93, in __getitem__
    self._move_item_to_end(item)
  File
"/home/seif/Projects/add_cache_for_get_events/zeitgeist/../_zeitgeist/lrucache.py",
line 115, in _move_item_to_end
    if item == self._list_start:
  File
"/home/seif/Projects/add_cache_for_get_events/zeitgeist/../_zeitgeist/lrucache.py",
line 38, in __cmp__
    return cmp(self.id, item.id)
AttributeError: 'NoneType' object has no attribute 'id'

Revision history for this message
Markus Korn (thekorn) wrote :

Seif, 1) is not unfixable ;) and I also don't see the new cache implementation as a significat speed improvement, compared to the previous version.

markus@thekorn ~/devel/zeitgeist/cache_benchmarks % cat result_small.csv
;1;2;5;10;50
../trunk_C;0,000442;0,0005326;0,0007516;0,0010958;0,0037418
../trunk_H;0,0001498;0,0002262;0,0004252;0,0007444;0,0033072
../add_cache_for_get_events_C;0,0004612;0,0005672;0,000823;0,0012218;0,0044662
../add_cache_for_get_events_H;0,0002604;0,0002824;0,0003426;0,0004424;0,0012568
/tmp/new_lru_C;0,000497;0,000592;0,00085;0,0012678;0,0045354
/tmp/new_lru_H;0,000262;0,0002828;0,0003394;0,0004176;0,0011164
markus@thekorn ~/devel/zeitgeist/cache_benchmarks % cat result_big.csv
;50;100;500;950;1000;1050;2000;3000
../trunk_C;0,0037996;0,0072462;0,0350788;0,0668402;0,0698766;0,0738612;0,1410332;0,213176
../trunk_H;0,0033512;0,006886;0,033931;0,0664788;0,0694176;0,0737804;0,1390564;0,2099196
../add_cache_for_get_events_C;0,0044968;0,0088392;0,0424282;0,0799834;0,085211;0,073756;0,142111;0,2141532
../add_cache_for_get_events_H;0,0012622;0,00226;0,0104036;0,0211476;0,0208178;0,0736068;0,1399124;0,210276
/tmp/new_lru_C;0,004724;0,0092024;0,043668;0,0835366;0,088505;0,0732062;0,141802;0,2119048
/tmp/new_lru_H;0,0011144;0,0019276;0,0087936;0,016713;0,0173434;0,0728648;0,1392448;0,222787
markus@thekorn ~/devel/zeitgeist/cache_benchmarks %

(the one in /tmp is this branch as it is right now, add_* is this branch at rev1649)

Revision history for this message
Seif Lotfy (seif) wrote :

Well I tried to fix it and just checking if "None" does not seem like a
solution to me. I couldn't recreate it over test cases. So I tried over
practical usage.
Unless you can fix it I think it won't hurt to use the new LRU code

1654. By Seif Lotfy

revert cache implementation for fixing

Revision history for this message
Markus Korn (thekorn) wrote :

[0] should fixes the issue reported by Seif in one of his comments, and adds a series of tests for it.

[0] http://paste.ubuntu.com/542815/

Revision history for this message
Seif Lotfy (seif) wrote :

OK applied the patch. Thank you Markus

1655. By Seif Lotfy

fix problems encountered in LRU Cache and added test cases

1656. By Seif Lotfy

updated from trunk

Revision history for this message
Seif Lotfy (seif) wrote :

@Markus:
the lrucache dumped out

Error from Zeitgeist engine: org.freedesktop.DBus.Python.KeyError: Traceback (most recent call last):
  File "/usr/lib/pymodules/python2.6/dbus/service.py", line 702, in _message_cb
    retval = candidate_method(self, *args, **keywords)
  File "/usr/local/share/zeitgeist/_zeitgeist/engine/remote.py", line 253, in FindEvents
    event_templates, storage_state, num_events, result_type, sender))
  File "/usr/local/share/zeitgeist/_zeitgeist/engine/main.py", line 461, in find_events
    return self._find_events(1, *args)
  File "/usr/local/share/zeitgeist/_zeitgeist/engine/main.py", line 451, in _find_events
    result = self.get_events(ids=[row[0] for row in result], sender=sender)
  File "/usr/local/share/zeitgeist/_zeitgeist/engine/main.py", line 241, in get_events
    self._event_cache[event.id] = event
  File "/usr/local/share/zeitgeist/_zeitgeist/lrucache.py", line 97, in __setitem__
    old = self.remove_eldest_item()
  File "/usr/local/share/zeitgeist/_zeitgeist/lrucache.py", line 146, in remove_eldest_item
    del self._map[old.key]
KeyError: 20320

Trying to replicate it

Revision history for this message
Seif Lotfy (seif) wrote :

And I just got it again

Error from Zeitgeist engine: org.freedesktop.DBus.Python.KeyError: Traceback (most recent call last):
  File "/usr/lib/pymodules/python2.6/dbus/service.py", line 702, in _message_cb
    retval = candidate_method(self, *args, **keywords)
  File "/usr/local/share/zeitgeist/_zeitgeist/engine/remote.py", line 253, in FindEvents
    event_templates, storage_state, num_events, result_type, sender))
  File "/usr/local/share/zeitgeist/_zeitgeist/engine/main.py", line 461, in find_events
    return self._find_events(1, *args)
  File "/usr/local/share/zeitgeist/_zeitgeist/engine/main.py", line 451, in _find_events
    result = self.get_events(ids=[row[0] for row in result], sender=sender)
  File "/usr/local/share/zeitgeist/_zeitgeist/engine/main.py", line 241, in get_events
    self._event_cache[event.id] = event
  File "/usr/local/share/zeitgeist/_zeitgeist/lrucache.py", line 97, in __setitem__
    old = self.remove_eldest_item()
  File "/usr/local/share/zeitgeist/_zeitgeist/lrucache.py", line 146, in remove_eldest_item
    del self._map[old.key]
KeyError: 19187

1657. By Seif Lotfy

merge with trunk

1658. By Seif Lotfy

fix key errors

1659. By Seif Lotfy

remove prints

Revision history for this message
Seif Lotfy (seif) wrote :

And again

Error from Zeitgeist engine: org.freedesktop.DBus.Python.KeyError: Traceback (most recent call last):
  File "/usr/lib/pymodules/python2.6/dbus/service.py", line 702, in _message_cb
    retval = candidate_method(self, *args, **keywords)
  File "/usr/local/share/zeitgeist/_zeitgeist/engine/remote.py", line 254, in FindEvents
    event_templates, storage_state, num_events, result_type, sender))
  File "/usr/local/share/zeitgeist/_zeitgeist/engine/main.py", line 461, in find_events
    return self._find_events(1, *args)
  File "/usr/local/share/zeitgeist/_zeitgeist/engine/main.py", line 451, in _find_events
    result = self.get_events(ids=[row[0] for row in result], sender=sender)
  File "/usr/local/share/zeitgeist/_zeitgeist/engine/main.py", line 241, in get_events
    self._event_cache[event.id] = event
  File "/usr/local/share/zeitgeist/_zeitgeist/lrucache.py", line 97, in __setitem__
    old = self.remove_eldest_item()
  File "/usr/local/share/zeitgeist/_zeitgeist/lrucache.py", line 146, in remove_eldest_item
    del self[old.key]
  File "/usr/local/share/zeitgeist/_zeitgeist/lrucache.py", line 71, in __delitem__
    item = self._map[key]
KeyError: 23795

1660. By Seif Lotfy

replace our lru implementation with a more stable one

Revision history for this message
Seif Lotfy (seif) wrote :

I think we should go with the online LRU implementation since it does not crash here and I cant find the issue with ours

Revision history for this message
Mikkel Kamstrup Erlandsen (kamstrup) wrote :

Seif; I don't think you got the lrucache.py sources from that link because the contained link to the source is a dead and I know this package from history. It's based on a heap (not a double linked list or deque) and is dog slow.

Also the AFL is incompatible with LGPL... http://www.gnu.org/licenses/license-list.html

Revision history for this message
Seif Lotfy (seif) wrote :

scratch that this is the wrong link

Revision history for this message
Markus Korn (thekorn) wrote :

Omg, not again, please. If you want to use this lru cache implementation, please create a debian package of this module, get it into debian/ubuntu and add this package as zg dependency. but please don't copy their code into ours (btw, I would consider it as rude if someone copies my code without leaving a comment pointing to the source of the code)

Seif, I'm sure we can fix our implmentation, *if* we know what's going wrong. And from what I see you where not able to provide this information. We know there is a KeyError in __delitem__ at some point. And we know that this is reproducable by you, that's all. Can you please give us more information on the state of the cache when it crashes? has the cache reached its max size? has the event with this id ever been in the cache?

Side note: please *never* merge this branch into lp:zeitgeist, it contains too much trash in the revision history, if you think you found the best solution for such cache please prepare a new clean branch and propose this branch for merge.

1661. By Seif Lotfy

repairt cache

Revision history for this message
Markus Korn (thekorn) wrote :

Okidoki, seif. Can you please summarize and comment on the last change you did (against the last revision with this cache implementation). AFAICS you removed _current_id and reformated the docstrings, is this it?
Which bug in the old code were you actually fixing? is this fixing the KeyError you got several times?
Can you please write a test for the case this revision is fixing?
Thanks

Revision history for this message
Seif Lotfy (seif) wrote :

On Mon, Jan 10, 2011 at 10:36 AM, Markus Korn <email address hidden> wrote:

> Okidoki, seif. Can you please summarize and comment on the last change you
> did (against the last revision with this cache implementation). AFAICS you
> removed _current_id and reformated the docstrings, is this it?
> Which bug in the old code were you actually fixing? is this fixing the
> KeyError you got several times?
> Can you please write a test for the case this revision is fixing?
> Thanks
> --
>
> https://code.launchpad.net/~seif/zeitgeist/add_cache_for_get_events/+merge/42327
> You are the owner of lp:~seif/zeitgeist/add_cache_for_get_events.
>

Yes, you used an incremental counter "current_id" that was redundant since
the keys for the cache could have been done over the "event id". I have been
running the branch now since I committed it in a terminal and noKeyError. I
will retrace the issue now again since it is in the current_id. But I think
this is it.

Revision history for this message
Markus Korn (thekorn) wrote :

I've seen discussions on memory usage in irc today (seif complaining about this cache using >30 MB writable memory), and I think I found a bug:

20:39 < thekorn> seiflotfy: I think I know what's making the cache size bigger
                 than max_size
20:39 < thekorn> seiflotfy: can you please remove old.next = None in line
                 125 of lrucache.py
20:40 < thekorn> and check writable memory again
20:41 < thekorn> if I get 4000 events with a cache size of 2000 I've 14MB
                 writable memory
20:41 < thekorn> which is far away from your 30MB

1662. By Seif Lotfy

removed old.next to fix the memory leak

Revision history for this message
Seif Lotfy (seif) wrote :

I am moving the work to a new clean branch lp:~seif/zeitgeist/fix-686732
Please lets continue there

Unmerged revisions

1662. By Seif Lotfy

removed old.next to fix the memory leak

1661. By Seif Lotfy

repairt cache

1660. By Seif Lotfy

replace our lru implementation with a more stable one

1659. By Seif Lotfy

remove prints

1658. By Seif Lotfy

fix key errors

1657. By Seif Lotfy

merge with trunk

1656. By Seif Lotfy

updated from trunk

1655. By Seif Lotfy

fix problems encountered in LRU Cache and added test cases

1654. By Seif Lotfy

revert cache implementation for fixing

1653. By Seif Lotfy

replaced lrucache

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file '_zeitgeist/Makefile.am'
2--- _zeitgeist/Makefile.am 2010-10-13 15:33:17 +0000
3+++ _zeitgeist/Makefile.am 2011-01-11 08:49:14 +0000
4@@ -4,4 +4,5 @@
5
6 app_PYTHON = \
7 __init__.py \
8- singleton.py
9+ singleton.py \
10+ lrucache.py
11
12=== modified file '_zeitgeist/engine/__init__.py'
13--- _zeitgeist/engine/__init__.py 2010-12-04 17:49:33 +0000
14+++ _zeitgeist/engine/__init__.py 2011-01-11 08:49:14 +0000
15@@ -60,5 +60,12 @@
16 CORE_SCHEMA_VERSION = 3
17
18 USER_EXTENSION_PATH = os.path.join(DATA_PATH, "extensions")
19+
20+ __default_cache_size = 2000
21+ if "ZEITGEIST_CACHE_SIZE" in os.environ:
22+ CACHE_SIZE = int(os.environ["ZEITGEIST_CACHE_SIZE"])
23+ else:
24+ CACHE_SIZE = __default_cache_size
25+ log.debug("Cache size = %i" %CACHE_SIZE)
26
27 constants = _Constants()
28
29=== modified file '_zeitgeist/engine/main.py'
30--- _zeitgeist/engine/main.py 2010-12-28 22:32:47 +0000
31+++ _zeitgeist/engine/main.py 2011-01-11 08:49:14 +0000
32@@ -34,6 +34,7 @@
33 from _zeitgeist.engine import constants
34 from _zeitgeist.engine.sql import get_default_cursor, unset_cursor, \
35 TableLookup, WhereClause
36+from _zeitgeist.lrucache import LRUCache
37
38 log = logging.getLogger("zeitgeist.engine")
39
40@@ -118,6 +119,7 @@
41 self._manifestation = TableLookup(cursor, "manifestation")
42 self._mimetype = TableLookup(cursor, "mimetype")
43 self._actor = TableLookup(cursor, "actor")
44+ self._event_cache = LRUCache(constants.CACHE_SIZE)
45
46 @property
47 def extensions(self):
48@@ -166,10 +168,22 @@
49 if not ids:
50 return []
51
52- rows = self._cursor.execute("""
53- SELECT * FROM event_view
54- WHERE id IN (%s)
55- """ % ",".join("%d" % id for id in ids)).fetchall()
56+ # Split ids into cached and uncached
57+ uncached_ids = []
58+ cached_ids = []
59+
60+ # Id ids batch greater than CACHE_SIZE ids ignore cache
61+ use_cache = True
62+ if len(ids) > constants.CACHE_SIZE/2:
63+ use_cache = False
64+ if not use_cache:
65+ uncached_ids = ids
66+ else:
67+ for id in ids:
68+ if id in self._event_cache:
69+ cached_ids.append(id)
70+ else:
71+ uncached_ids.append(id)
72
73 id_hash = defaultdict(list)
74 for n, id in enumerate(ids):
75@@ -183,10 +197,36 @@
76 # deleted
77 events = {}
78 sorted_events = [None]*len(ids)
79+
80+ for id in cached_ids:
81+ event = self._event_cache[id]
82+ if event:
83+ event = self.extensions.apply_get_hooks(event, sender)
84+ if event is not None:
85+ for n in id_hash[event.id]:
86+ # insert the event into all necessary spots (LP: #673916)
87+ sorted_events[n] = event
88+
89+ # Get uncached events
90+ rows = self._cursor.execute("""
91+ SELECT * FROM event_view
92+ WHERE id IN (%s)
93+ """ % ",".join("%d" % id for id in uncached_ids)).fetchall()
94+
95+ log.debug("Got %d raw events in %fs" % (len(rows), time.time()-t))
96+ t = time.time()
97+
98+ t_get_event = 0
99+ t_get_subject = 0
100+ t_apply_get_hooks = 0
101+
102 for row in rows:
103 # Assumption: all rows of a same event for its different
104 # subjects are in consecutive order.
105+ t_get_event -= time.time()
106 event = self._get_event_from_row(row)
107+ t_get_event += time.time()
108+
109 if event:
110 # Check for existing event.id in event to attach
111 # other subjects to it
112@@ -194,14 +234,28 @@
113 events[event.id] = event
114 else:
115 event = events[event.id]
116+
117+ # Avoid caching events with payloads to have keep the cache MB size
118+ # at a decent level
119+ if use_cache and not event.payload:
120+ self._event_cache[event.id] = event
121+
122+ t_get_subject -= time.time()
123 event.append_subject(self._get_subject_from_row(row))
124+ t_get_subject += time.time()
125+
126+ t_apply_get_hooks -= time.time()
127 event = self.extensions.apply_get_hooks(event, sender)
128+ t_apply_get_hooks += time.time()
129 if event is not None:
130 for n in id_hash[event.id]:
131 # insert the event into all necessary spots (LP: #673916)
132 sorted_events[n] = event
133-
134+
135 log.debug("Got %d events in %fs" % (len(sorted_events), time.time()-t))
136+ log.debug(" Where time spent in _get_event_from_row in %fs" % (t_get_event))
137+ log.debug(" Where time spent in _get_subject_from_row in %fs" % (t_get_subject))
138+ log.debug(" Where time spent in apply_get_hooks in %fs" % (t_apply_get_hooks))
139 return sorted_events
140
141 @staticmethod
142@@ -604,6 +658,11 @@
143 """ % ",".join(str(int(_id)) for _id in ids))
144 timestamps = self._cursor.fetchone()
145
146+ # Remove events from cache
147+ for id in ids:
148+ if id in self._event_cache:
149+ del self._event_cache[id]
150+
151 # Make sure that we actually found some events with these ids...
152 # We can't do all(timestamps) here because the timestamps may be 0
153 if timestamps and timestamps[0] is not None and timestamps[1] is not None:
154@@ -613,7 +672,6 @@
155 log.debug("Deleted %s" % map(int, ids))
156
157 self.extensions.apply_post_delete(ids, sender)
158-
159 return timestamps
160 else:
161 log.debug("Tried to delete non-existing event(s): %s" % map(int, ids))
162
163=== added file '_zeitgeist/lrucache.py'
164--- _zeitgeist/lrucache.py 1970-01-01 00:00:00 +0000
165+++ _zeitgeist/lrucache.py 2011-01-11 08:49:14 +0000
166@@ -0,0 +1,125 @@
167+# -.- coding: utf-8 -.-
168+
169+# lrucache.py
170+#
171+# Copyright © 2009 Mikkel Kamstrup Erlandsen <mikkel.kamstrup@gmail.com>
172+# Copyright © 2009 Markus Korn <thekorn@gmx.de>
173+# Copyright © 2011 Seif Lotfy <seif@lotfy.com>
174+#
175+# This program is free software: you can redistribute it and/or modify
176+# it under the terms of the GNU Lesser General Public License as published by
177+# the Free Software Foundation, either version 3 of the License, or
178+# (at your option) any later version.
179+#
180+# This program is distributed in the hope that it will be useful,
181+# but WITHOUT ANY WARRANTY; without even the implied warranty of
182+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
183+# GNU Lesser General Public License for more details.
184+#
185+# You should have received a copy of the GNU Lesser General Public License
186+# along with this program. If not, see <http://www.gnu.org/licenses/>.
187+
188+class LRUCache:
189+ """
190+ A simple LRUCache implementation backed by a linked list and a dict.
191+ It can be accessed and updated just like a dict. To check if an element
192+ exists in the cache the following type of statements can be used:
193+ if "foo" in cache
194+ """
195+
196+ class _Item:
197+ """
198+ A container for each item in LRUCache which knows about the
199+ item's position and relations
200+ """
201+ def __init__(self, item_key, item_value):
202+ self.value = item_value
203+ self.key = item_key
204+ self.next = None
205+ self.prev = None
206+
207+ def __init__(self, max_size):
208+ """
209+ The size of the cache (in number of cached items) is guaranteed to
210+ never exceed 'size'
211+ """
212+ self._max_size = max_size
213+ self.clear()
214+
215+
216+ def clear(self):
217+ self._list_end = None # The newest item
218+ self._list_start = None # Oldest item
219+ self._map = {}
220+
221+ def __len__(self):
222+ return len(self._map)
223+
224+ def __contains__(self, key):
225+ return key in self._map
226+
227+ def __delitem__(self, key):
228+ item = self._map[key]
229+ if item.prev:
230+ item.prev.next = item.next
231+ else:
232+ # we are deleting the first item, so we need a new first one
233+ self._list_start = item.next
234+ if item.next:
235+ item.next.prev = item.prev
236+ else:
237+ # we are deleting the last item, get a new last one
238+ self._list_end = item.prev
239+ del self._map[key], item
240+
241+ def __setitem__(self, key, value):
242+ if key in self._map:
243+ item = self._map[key]
244+ item.value = value
245+ self._move_item_to_end(item)
246+ else:
247+ new = LRUCache._Item(key, value)
248+ self._append_to_list(new)
249+
250+ if len(self._map) > self._max_size :
251+ # Remove eldest entry from list
252+ self.remove_eldest_item()
253+
254+ def __getitem__(self, key):
255+ item = self._map[key]
256+ self._move_item_to_end(item)
257+ return item.value
258+
259+ def __iter__(self):
260+ """
261+ Iteration is in order from eldest to newest,
262+ and returns (key,value) tuples
263+ """
264+ iter = self._list_start
265+ while iter != None:
266+ yield (iter.key, iter.value)
267+ iter = iter.next
268+
269+ def _move_item_to_end(self, item):
270+ del self[item.key]
271+ self._append_to_list(item)
272+
273+ def _append_to_list(self, item):
274+ self._map[item.key] = item
275+ if not self._list_start:
276+ self._list_start = item
277+ if self._list_end:
278+ self._list_end.next = item
279+ item.prev = self._list_end
280+ item.next = None
281+ self._list_end = item
282+
283+ def remove_eldest_item(self):
284+ if self._list_start == self._list_end:
285+ self._list_start = None
286+ self._list_end = None
287+ return
288+ old = self._list_start
289+ old.next.prev = None
290+ self._list_start = old.next
291+ del self[old.key], old
292\ No newline at end of file
293
294=== added file 'test/lrucache_test.py'
295--- test/lrucache_test.py 1970-01-01 00:00:00 +0000
296+++ test/lrucache_test.py 2011-01-11 08:49:14 +0000
297@@ -0,0 +1,98 @@
298+#!/usr/bin/python
299+
300+# Update python path to use local zeitgeist module
301+import sys
302+import os
303+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
304+
305+from _zeitgeist.lrucache import LRUCache
306+
307+import unittest
308+
309+class LRUCacheTest(unittest.TestCase):
310+
311+ def testPutGetOne(self):
312+ """Test that we can cache and then retrieve one single item"""
313+ cache = LRUCache(10)
314+ cache["foo"] = "bar"
315+ self.assertEquals("bar", cache["foo"])
316+ self.assertRaises(KeyError, lambda : cache["nosuchelement"])
317+
318+ def testPutGetTwo(self):
319+ """Test that we can cache and then retrieve two items"""
320+ cache = LRUCache(10)
321+ cache["foo1"] = "bar1"
322+ cache["foo2"] = "bar2"
323+ self.assertEquals("bar1", cache["foo1"])
324+ self.assertEquals("bar2", cache["foo2"])
325+ self.assertRaises(KeyError, lambda : cache["nosuchelement"])
326+
327+ def testExceedMaxSize(self):
328+ """Test that we can restrict the cache size to one element, and that
329+ this one element is the latest one we've added"""
330+ cache = LRUCache(1)
331+ cache["foo1"] = "bar1"
332+ cache["foo2"] = "bar2"
333+ self.assertRaises(KeyError, lambda : cache["foo1"])
334+ self.assertEquals("bar2", cache["foo2"])
335+ self.assertEquals(1, len(cache))
336+
337+ def testInKeyword(self):
338+ """Make sure we can do 'if "foo" in cache' type of statements"""
339+ cache = LRUCache(5)
340+ cache["foo1"] = "bar1"
341+ self.assertFalse("bork" in cache)
342+ self.assertTrue("foo1" in cache)
343+
344+ def testIteration(self):
345+ """Make sure that iteration is in the correct order; oldest to newest"""
346+ cache = LRUCache(4)
347+ cache["foo1"] = "bar1"
348+ cache["foo2"] = "bar2"
349+ cache["foo3"] = "bar3"
350+ cache["foo4"] = "bar4"
351+ cache["foo1"] = "bar1" # "foo1" should now be newest
352+
353+ l = []
354+ for key_val in cache : l.append(key_val)
355+ self.assertEquals([("foo2", "bar2"),
356+ ("foo3", "bar3"),
357+ ("foo4", "bar4"),
358+ ("foo1", "bar1")], l)
359+
360+ def testDeleteItem(self):
361+ cache = LRUCache(4)
362+ cache["foo1"] = "bar1"
363+ cache["foo2"] = "bar2"
364+ cache["foo3"] = "bar3"
365+ cache["foo4"] = "bar4"
366+ self.assertTrue("foo2" in cache)
367+ del cache["foo2"] # delete item in the middle of the cache
368+ self.assertTrue("foo2" not in cache)
369+ self.assertEquals(
370+ [("foo1", "bar1"), ("foo3", "bar3"), ("foo4", "bar4")],
371+ list(cache)
372+ )
373+ self.assertTrue("foo1" in cache)
374+ del cache["foo1"] # delete first item
375+ self.assertTrue("foo1" not in cache)
376+ self.assertEquals(
377+ [("foo3", "bar3"), ("foo4", "bar4")],
378+ list(cache)
379+ )
380+ self.assertTrue("foo4" in cache)
381+ del cache["foo4"] # delete last item
382+ self.assertTrue("foo4" not in cache)
383+ self.assertEquals(
384+ [("foo3", "bar3")],
385+ list(cache)
386+ )
387+ del cache["foo3"]
388+ self.assertTrue("foo3" not in cache)
389+ self.assertEquals([], list(cache))
390+ self.assertTrue(cache._list_start is None)
391+ self.assertTrue(cache._list_end is None)
392+
393+
394+if __name__ == '__main__':
395+ unittest.main()
396\ No newline at end of file
397
398=== modified file 'zeitgeist/client.py'
399--- zeitgeist/client.py 2010-12-26 09:18:11 +0000
400+++ zeitgeist/client.py 2011-01-11 08:49:14 +0000
401@@ -919,4 +919,4 @@
402 normal_reply_handler(normal_reply_data)
403
404 _FIND_EVENTS_FOR_TEMPLATES_ARGS = inspect.getargspec(
405- ZeitgeistClient.find_events_for_templates)[0]
406+ ZeitgeistClient.find_events_for_templates)[0]
407\ No newline at end of file

Subscribers

People subscribed via source and target branches