• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zopefoundation / Products.StandardCacheManagers / 17528312011

17 Mar 2025 07:54AM UTC coverage: 50.611% (-2.0%) from 52.597%
17528312011

push

github

web-flow
Update Python version support. (#13)

* Drop support for Python 3.8.
* Add support for Python 3.13.

13 of 124 branches covered (10.48%)

Branch coverage included in aggregate %.

360 of 613 relevant lines covered (58.73%)

0.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

25.87
/src/Products/StandardCacheManagers/RAMCacheManager.py
1
##############################################################################
2
#
3
# Copyright (c) 2002 Zope Foundation and Contributors.
4
#
5
# This software is subject to the provisions of the Zope Public License,
6
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
7
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
8
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
9
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
10
# FOR A PARTICULAR PURPOSE
11
#
12
##############################################################################
13
'''
14
RAM cache manager --
15
  Caches the results of method calls in RAM.
16
'''
17
import time
1✔
18
from _thread import allocate_lock
1✔
19
from html import escape
1✔
20
from operator import itemgetter
1✔
21
from pickle import HIGHEST_PROTOCOL
1✔
22
from pickle import Pickler
1✔
23

24
from AccessControl.class_init import InitializeClass
1✔
25
from AccessControl.Permissions import view_management_screens
1✔
26
from AccessControl.SecurityInfo import ClassSecurityInfo
1✔
27
from App.special_dtml import DTMLFile
1✔
28
from OFS.Cache import Cache
1✔
29
from OFS.Cache import CacheManager
1✔
30
from OFS.SimpleItem import SimpleItem
1✔
31

32

33
_marker = []  # Create a new marker object.
1✔
34
caches = {}
1✔
35
PRODUCT_DIR = __name__.split('.')[-2]
1✔
36

37

38
class CacheException(Exception):
1✔
39
    '''
40
    A cache-related exception.
41
    '''
42

43

44
class CacheEntry:
1✔
45
    '''
46
    Represents a cached value.
47
    '''
48

49
    def __init__(self, index, data, view_name):
1✔
50
        try:
×
51
            # This is a protective barrier that hopefully prevents
52
            # us from caching something that might result in memory
53
            # leaks.  It's also convenient for determining the
54
            # approximate memory usage of the cache entry.
55
            # DM 2004-11-29: this code causes excessive time.
56
            #   Note also that it does not prevent us from
57
            #   caching objects with references to persistent objects
58
            #   When we do, nasty persistency errors are likely
59
            #   to occur ("shouldn't load data while connection is closed").
60
            sizer = _ByteCounter()
×
61
            pickler = Pickler(sizer, HIGHEST_PROTOCOL)
×
62
            pickler.dump(index)
×
63
            pickler.dump(data)
×
64
            self.size = sizer.getCount()
×
65
        except Exception:
×
66
            raise CacheException('The data for the cache is not pickleable.')
×
67
        self.created = time.time()
×
68
        self.data = data
×
69
        self.view_name = view_name
×
70
        self.access_count = 0
×
71

72

73
class ObjectCacheEntries:
1✔
74
    '''
75
    Represents the cache for one Zope object.
76
    '''
77

78
    hits = 0
1✔
79
    misses = 0
1✔
80

81
    def __init__(self, path):
1✔
82
        self.physical_path = path
×
83
        self.lastmod = 0  # Mod time of the object, class, etc.
×
84
        self.entries = {}
×
85

86
    def aggregateIndex(self, view_name, req, req_names, local_keys):
1✔
87
        '''
88
        Returns the index to be used when looking for or inserting
89
        a cache entry.
90
        view_name is a string.
91
        local_keys is a mapping or None.
92
        '''
93
        req_index = []
×
94
        # Note: req_names is already sorted.
95
        for key in req_names:
×
96
            if req is None:
×
97
                val = ''
×
98
            else:
99
                val = req.get(key, '')
×
100
            req_index.append((str(key), str(val)))
×
101
        if local_keys:
×
102
            local_index = []
×
103
            for key, val in local_keys.items():
×
104
                local_index.append((str(key), str(val)))
×
105
            local_index.sort()
×
106
        else:
107
            local_index = ()
×
108
        return (str(view_name), tuple(req_index), tuple(local_index))
×
109

110
    def getEntry(self, lastmod, index):
1✔
111
        if self.lastmod < lastmod:
×
112
            # Expired.
113
            self.entries = {}
×
114
            self.lastmod = lastmod
×
115
            return _marker
×
116
        return self.entries.get(index, _marker)
×
117

118
    def setEntry(self, lastmod, index, data, view_name):
1✔
119
        self.lastmod = lastmod
×
120
        self.entries[index] = CacheEntry(index, data, view_name)
×
121

122
    def delEntry(self, index):
1✔
123
        try:
×
124
            del self.entries[index]
×
125
        except KeyError:
×
126
            pass
×
127

128

129
class RAMCache(Cache):
1✔
130
    # Note the need to take thread safety into account.
131
    # Also note that objects of this class are not persistent,
132
    # nor do they make use of acquisition.
133
    max_age = 0
1✔
134

135
    def __init__(self):
1✔
136
        # cache maps physical paths to ObjectCacheEntries.
137
        self.cache = {}
1✔
138
        self.writelock = allocate_lock()
1✔
139
        self.next_cleanup = 0
1✔
140

141
    def initSettings(self, kw):
1✔
142
        # Note that we lazily allow RAMCacheManager
143
        # to verify the correctness of the internal settings.
144
        self.__dict__.update(kw)
1✔
145

146
    def getObjectCacheEntries(self, ob, create=0):
1✔
147
        """
148
        Finds or creates the associated ObjectCacheEntries object.
149
        Remember to lock writelock when calling with the 'create' flag.
150
        """
151
        cache = self.cache
×
152
        path = ob.getPhysicalPath()
×
153
        oc = cache.get(path, None)
×
154
        if oc is None:
×
155
            if create:
×
156
                cache[path] = oc = ObjectCacheEntries(path)
×
157
            else:
158
                return None
×
159
        return oc
×
160

161
    def countAllEntries(self):
1✔
162
        '''
163
        Returns the count of all cache entries.
164
        '''
165
        count = 0
×
166
        for oc in self.cache.values():
×
167
            count = count + len(oc.entries)
×
168
        return count
×
169

170
    def countAccesses(self):
1✔
171
        '''
172
        Returns a mapping of
173
        (n) -> number of entries accessed (n) times
174
        '''
175
        counters = {}
×
176
        for oc in self.cache.values():
×
177
            for entry in oc.entries.values():
×
178
                access_count = entry.access_count
×
179
                counters[access_count] = counters.get(
×
180
                    access_count, 0) + 1
181
        return counters
×
182

183
    def clearAccessCounters(self):
1✔
184
        '''
185
        Clears access_count for each cache entry.
186
        '''
187
        for oc in self.cache.values():
×
188
            for entry in oc.entries.values():
×
189
                entry.access_count = 0
×
190

191
    def deleteEntriesAtOrBelowThreshold(self, threshold_access_count):
1✔
192
        """
193
        Deletes entries that haven't been accessed recently.
194
        """
195
        self.writelock.acquire()
×
196
        try:
×
197
            for p, oc in list(self.cache.items()):
×
198
                for agindex, entry in list(oc.entries.items()):
×
199
                    if entry.access_count <= threshold_access_count:
×
200
                        del oc.entries[agindex]
×
201
                if len(oc.entries) < 1:
×
202
                    del self.cache[p]
×
203
        finally:
204
            self.writelock.release()
×
205

206
    def deleteStaleEntries(self):
1✔
207
        """
208
        Deletes entries that have expired.
209
        """
210
        if self.max_age > 0:
×
211
            self.writelock.acquire()
×
212
            try:
×
213
                min_created = time.time() - self.max_age
×
214
                for p, oc in list(self.cache.items()):
×
215
                    for agindex, entry in list(oc.entries.items()):
×
216
                        if entry.created < min_created:
×
217
                            del oc.entries[agindex]
×
218
                    if len(oc.entries) < 1:
×
219
                        del self.cache[p]
×
220
            finally:
221
                self.writelock.release()
×
222

223
    def cleanup(self):
1✔
224
        '''
225
        Removes cache entries.
226
        '''
227
        self.deleteStaleEntries()
×
228
        new_count = self.countAllEntries()
×
229
        if new_count > self.threshold:
×
230
            counters = self.countAccesses()
×
231
            priorities = sorted(counters.items())
×
232
            # Remove the least accessed entries until we've reached
233
            # our target count.
234
            if len(priorities) > 0:
×
235
                access_count = 0
×
236
                for access_count, effect in priorities:
×
237
                    new_count = new_count - effect
×
238
                    if new_count <= self.threshold:
×
239
                        break
×
240
                self.deleteEntriesAtOrBelowThreshold(access_count)
×
241
                self.clearAccessCounters()
×
242

243
    def getCacheReport(self):
1✔
244
        """
245
        Reports on the contents of the cache.
246
        """
247
        rval = []
×
248
        for oc in self.cache.values():
×
249
            size = 0
×
250
            ac = 0
×
251
            views = []
×
252
            for entry in oc.entries.values():
×
253
                size = size + entry.size
×
254
                ac = ac + entry.access_count
×
255
                view = entry.view_name or '<default>'
×
256
                if isinstance(view, bytes):
×
257
                    view = view.decode('UTF-8')
×
258
                if view not in views:
×
259
                    views.append(view)
×
260
            views.sort()
×
261
            info = {'path': '/'.join(oc.physical_path),
×
262
                    'hits': oc.hits,
263
                    'misses': oc.misses,
264
                    'size': size,
265
                    'counter': ac,
266
                    'views': views,
267
                    'entries': len(oc.entries),
268
                    }
269
            rval.append(info)
×
270
        return rval
×
271

272
    def ZCache_invalidate(self, ob):
1✔
273
        '''
274
        Invalidates the cache entries that apply to ob.
275
        '''
276
        path = ob.getPhysicalPath()
×
277
        # Invalidates all subobjects as well.
278
        self.writelock.acquire()
×
279
        try:
×
280
            for p, oc in list(self.cache.items()):
×
281
                pp = oc.physical_path
×
282
                if pp[:len(path)] == path:
×
283
                    del self.cache[p]
×
284
        finally:
285
            self.writelock.release()
×
286

287
    def ZCache_get(self, ob, view_name='', keywords=None,
1✔
288
                   mtime_func=None, default=None):
289
        '''
290
        Gets a cache entry or returns default.
291
        '''
292
        oc = self.getObjectCacheEntries(ob)
×
293
        if oc is None:
×
294
            return default
×
295
        lastmod = ob.ZCacheable_getModTime(mtime_func)
×
296

297
        index = oc.aggregateIndex(view_name, getattr(ob, 'REQUEST', None),
×
298
                                  self.request_vars, keywords)
299
        entry = oc.getEntry(lastmod, index)
×
300
        if entry is _marker:
×
301
            return default
×
302
        if self.max_age > 0 and entry.created < time.time() - self.max_age:
×
303
            # Expired.
304
            self.writelock.acquire()
×
305
            try:
×
306
                oc.delEntry(index)
×
307
            finally:
308
                self.writelock.release()
×
309
            return default
×
310
        oc.hits = oc.hits + 1
×
311
        entry.access_count = entry.access_count + 1
×
312
        return entry.data
×
313

314
    def ZCache_set(self, ob, data, view_name='', keywords=None,
1✔
315
                   mtime_func=None):
316
        '''
317
        Sets a cache entry.
318
        '''
319
        now = time.time()
×
320
        if self.next_cleanup <= now:
×
321
            self.cleanup()
×
322
            self.next_cleanup = now + self.cleanup_interval
×
323

324
        lastmod = ob.ZCacheable_getModTime(mtime_func)
×
325
        self.writelock.acquire()
×
326
        try:
×
327
            oc = self.getObjectCacheEntries(ob, create=1)
×
328
            index = oc.aggregateIndex(view_name, getattr(ob, 'REQUEST', None),
×
329
                                      self.request_vars, keywords)
330
            oc.setEntry(lastmod, index, data, view_name)
×
331
            oc.misses = oc.misses + 1
×
332
        finally:
333
            self.writelock.release()
×
334

335

336
class RAMCacheManager(CacheManager, SimpleItem):
1✔
337
    """Manage a RAMCache, which stores rendered data in RAM.
338

339
    This is intended to be used as a low-level cache for
340
    expensive Python code, not for objects published
341
    under their own URLs such as web pages.
342

343
    RAMCacheManager *can* be used to cache complete publishable
344
    pages, such as DTMLMethods/Documents and Page Templates,
345
    but this is not advised: such objects typically do not attempt
346
    to cache important out-of-band data such as 3xx HTTP responses,
347
    and the client would get an erroneous 200 response.
348

349
    Such objects should instead be cached with an
350
    AcceleratedHTTPCacheManager and/or downstream
351
    caching.
352
    """
353

354
    security = ClassSecurityInfo()
1✔
355
    security.setPermissionDefault('Change cache managers', ('Manager', ))
1✔
356

357
    manage_options = (
1✔
358
        {'label': 'Properties', 'action': 'manage_main'},
359
        {'label': 'Statistics', 'action': 'manage_stats'},
360
    ) + CacheManager.manage_options + SimpleItem.manage_options
361

362
    meta_type = 'RAM Cache Manager'
1✔
363
    zmi_icon = 'fas fa-forward'
1✔
364

365
    def __init__(self, ob_id):
1✔
366
        self.id = ob_id
1✔
367
        self.title = ''
1✔
368
        self._settings = {
1✔
369
            'threshold': 1000,
370
            'cleanup_interval': 300,
371
            'request_vars': ('AUTHENTICATED_USER', ),
372
            'max_age': 3600,
373
        }
374
        self._resetCacheId()
1✔
375

376
    def getId(self):
1✔
377
        ' '
378
        return self.id
1✔
379

380
    @security.private
1✔
381
    def _remove_data(self):
1✔
382
        caches.pop(self.__cacheid, None)
1✔
383

384
    @security.private
1✔
385
    def _resetCacheId(self):
1✔
386
        self.__cacheid = f'{id(self)}_{time.time():f}'
1✔
387

388
    ZCacheManager_getCache__roles__ = ()
1✔
389

390
    def ZCacheManager_getCache(self):
1✔
391
        cacheid = self.__cacheid
1✔
392
        try:
1✔
393
            return caches[cacheid]
1✔
394
        except KeyError:
1✔
395
            cache = RAMCache()
1✔
396
            cache.initSettings(self._settings)
1✔
397
            caches[cacheid] = cache
1✔
398
            return cache
1✔
399

400
    @security.protected(view_management_screens)
1✔
401
    def getSettings(self):
1✔
402
        'Returns the current cache settings.'
403
        res = self._settings.copy()
×
404
        if 'max_age' not in res:
×
405
            res['max_age'] = 0
×
406
        return res
×
407

408
    security.declareProtected(view_management_screens,  # NOQA: D001
1✔
409
                              'manage_main')
410
    manage_main = DTMLFile('dtml/propsRCM', globals())
1✔
411

412
    @security.protected('Change cache managers')
1✔
413
    def manage_editProps(self, title, settings=None, REQUEST=None):
1✔
414
        'Changes the cache settings.'
415
        if settings is None:
×
416
            settings = REQUEST
×
417
        self.title = str(title)
×
418
        request_vars = list(settings['request_vars'])
×
419
        request_vars.sort()
×
420
        self._settings = {
×
421
            'threshold': int(settings['threshold']),
422
            'cleanup_interval': int(settings['cleanup_interval']),
423
            'request_vars': tuple(request_vars),
424
            'max_age': int(settings['max_age']),
425
        }
426
        cache = self.ZCacheManager_getCache()
×
427
        cache.initSettings(self._settings)
×
428
        if REQUEST is not None:
×
429
            return self.manage_main(
×
430
                self, REQUEST, manage_tabs_message='Properties changed.')
431

432
    security.declareProtected(view_management_screens,  # NOQA: D001
1✔
433
                              'manage_stats')
434
    manage_stats = DTMLFile('dtml/statsRCM', globals())
1✔
435

436
    def _getSortInfo(self):
1✔
437
        """
438
        Returns the value of sort_by and sort_reverse.
439
        If not found, returns default values.
440
        """
441
        req = self.REQUEST
×
442
        sort_by = req.get('sort_by', 'hits')
×
443
        sort_reverse = int(req.get('sort_reverse', 1))
×
444
        return sort_by, sort_reverse
×
445

446
    @security.protected(view_management_screens)
1✔
447
    def getCacheReport(self):
1✔
448
        """
449
        Returns the list of objects in the cache, sorted according to
450
        the user's preferences.
451
        """
452
        sort_by, sort_reverse = self._getSortInfo()
×
453
        c = self.ZCacheManager_getCache()
×
454
        rval = c.getCacheReport()
×
455
        if sort_by:
×
456
            rval.sort(key=itemgetter(sort_by), reverse=sort_reverse)
×
457
        return rval
×
458

459
    @security.protected(view_management_screens)
1✔
460
    def sort_link(self, name, id):
1✔
461
        """
462
        Utility for generating a sort link.
463
        """
464
        sort_by, sort_reverse = self._getSortInfo()
×
465
        url = self.absolute_url() + '/manage_stats?sort_by=' + id
×
466
        newsr = 0
×
467
        if sort_by == id:
×
468
            newsr = not sort_reverse
×
469
        url = url + '&sort_reverse=' + (newsr and '1' or '0')
×
470
        return f'<a href="{escape(url, 1)}">{escape(name)}</a>'
×
471

472
    @security.protected('Change cache managers')
1✔
473
    def manage_invalidate(self, paths, REQUEST=None):
1✔
474
        """ ZMI helper to invalidate an entry """
475
        for path in paths:
×
476
            try:
×
477
                ob = self.unrestrictedTraverse(path)
×
478
            except (AttributeError, KeyError):
×
479
                pass
×
480

481
            ob.ZCacheable_invalidate()
×
482

483
        if REQUEST is not None:
×
484
            msg = 'Cache entries invalidated'
×
485
            return self.manage_stats(manage_tabs_message=msg)
×
486

487

488
InitializeClass(RAMCacheManager)
1✔
489

490

491
class _ByteCounter:
1✔
492
    '''auxiliary file like class which just counts the bytes written.'''
493
    _count = 0
1✔
494

495
    def write(self, bytes):
1✔
496
        self._count += len(bytes)
×
497

498
    def getCount(self):
1✔
499
        return self._count
×
500

501

502
manage_addRAMCacheManagerForm = DTMLFile('dtml/addRCM', globals())
1✔
503

504

505
def manage_addRAMCacheManager(self, id, REQUEST=None):
1✔
506
    'Adds a RAM cache manager to the folder.'
507
    self._setObject(id, RAMCacheManager(id))
×
508
    if REQUEST is not None:
×
509
        return self.manage_main(self, REQUEST)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc