<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[14970] CalendarServer/trunk/calendarserver/tools</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta">
<dt>Revision</dt> <dd><a href="http://trac.calendarserver.org//changeset/14970">14970</a></dd>
<dt>Author</dt> <dd>cdaboo@apple.com</dd>
<dt>Date</dt> <dd>2015-07-15 10:25:10 -0700 (Wed, 15 Jul 2015)</dd>
</dl>

<h3>Log Message</h3>
<pre>Merge old event purge changes from -5.4-dev branch.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#CalendarServertrunkcalendarservertoolsgatewaypy">CalendarServer/trunk/calendarserver/tools/gateway.py</a></li>
<li><a href="#CalendarServertrunkcalendarservertoolspurgepy">CalendarServer/trunk/calendarserver/tools/purge.py</a></li>
<li><a href="#CalendarServertrunkcalendarservertoolstesttest_purge_old_eventspy">CalendarServer/trunk/calendarserver/tools/test/test_purge_old_events.py</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="CalendarServertrunkcalendarservertoolsgatewaypy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/calendarserver/tools/gateway.py (14969 => 14970)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/calendarserver/tools/gateway.py        2015-07-15 16:10:33 UTC (rev 14969)
+++ CalendarServer/trunk/calendarserver/tools/gateway.py        2015-07-15 17:25:10 UTC (rev 14970)
</span><span class="lines">@@ -577,7 +577,7 @@
</span><span class="cx">         cutoff = DateTime.getToday()
</span><span class="cx">         cutoff.setDateOnly(False)
</span><span class="cx">         cutoff.offsetDay(-retainDays)
</span><del>-        eventCount = (yield PurgeOldEventsService.purgeOldEvents(self.store, cutoff, DEFAULT_BATCH_SIZE))
</del><ins>+        eventCount = (yield PurgeOldEventsService.purgeOldEvents(self.store, None, cutoff, DEFAULT_BATCH_SIZE))
</ins><span class="cx">         self.respond(command, {'EventsRemoved': eventCount, &quot;RetainDays&quot;: retainDays})
</span><span class="cx"> 
</span><span class="cx"> 
</span></span></pre></div>
<a id="CalendarServertrunkcalendarservertoolspurgepy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/calendarserver/tools/purge.py (14969 => 14970)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/calendarserver/tools/purge.py        2015-07-15 16:10:33 UTC (rev 14969)
+++ CalendarServer/trunk/calendarserver/tools/purge.py        2015-07-15 17:25:10 UTC (rev 14970)
</span><span class="lines">@@ -29,7 +29,7 @@
</span><span class="cx"> from pycalendar.datetime import DateTime
</span><span class="cx"> 
</span><span class="cx"> from twext.enterprise.dal.record import fromTable
</span><del>-from twext.enterprise.dal.syntax import Delete, Select, Union
</del><ins>+from twext.enterprise.dal.syntax import Delete, Select, Union, Parameter, Max
</ins><span class="cx"> from twext.enterprise.jobqueue import WorkItem, RegeneratingWorkItem
</span><span class="cx"> from twext.python.log import Logger
</span><span class="cx"> 
</span><span class="lines">@@ -37,9 +37,11 @@
</span><span class="cx"> 
</span><span class="cx"> from twistedcaldav import caldavxml
</span><span class="cx"> from twistedcaldav.config import config
</span><ins>+from twistedcaldav.dateops import parseSQLDateToPyCalendar, pyCalendarToSQLTimestamp
+from twistedcaldav.ical import Component, InvalidICalendarDataError
</ins><span class="cx"> 
</span><span class="cx"> from txdav.caldav.datastore.query.filter import Filter
</span><del>-from txdav.common.datastore.sql_tables import schema, _HOME_STATUS_NORMAL
</del><ins>+from txdav.common.datastore.sql_tables import schema, _HOME_STATUS_NORMAL, _BIND_MODE_OWN
</ins><span class="cx"> 
</span><span class="cx"> log = Logger()
</span><span class="cx"> 
</span><span class="lines">@@ -277,10 +279,11 @@
</span><span class="cx"> 
</span><span class="cx"> class PurgeOldEventsService(WorkerService):
</span><span class="cx"> 
</span><ins>+    uuid = None
</ins><span class="cx">     cutoff = None
</span><span class="cx">     batchSize = None
</span><span class="cx">     dryrun = False
</span><del>-    verbose = False
</del><ins>+    debug = False
</ins><span class="cx"> 
</span><span class="cx">     @classmethod
</span><span class="cx">     def usage(cls, e=None):
</span><span class="lines">@@ -293,8 +296,8 @@
</span><span class="cx">         print(&quot;options:&quot;)
</span><span class="cx">         print(&quot;  -h --help: print this help and exit&quot;)
</span><span class="cx">         print(&quot;  -f --config &lt;path&gt;: Specify caldavd.plist configuration path&quot;)
</span><ins>+        print(&quot;  -u --uuid &lt;uuid&gt;: Only process this user(s) [REQUIRED]&quot;)
</ins><span class="cx">         print(&quot;  -d --days &lt;number&gt;: specify how many days in the past to retain (default=%d)&quot; % (DEFAULT_RETAIN_DAYS,))
</span><del>-        # print(&quot;  -b --batch &lt;number&gt;: number of events to remove in each transaction (default=%d)&quot; % (DEFAULT_BATCH_SIZE,))
</del><span class="cx">         print(&quot;  -n --dry-run: calculate how many events to purge, but do not purge data&quot;)
</span><span class="cx">         print(&quot;  -v --verbose: print progress information&quot;)
</span><span class="cx">         print(&quot;  -D --debug: debug logging&quot;)
</span><span class="lines">@@ -312,11 +315,12 @@
</span><span class="cx"> 
</span><span class="cx">         try:
</span><span class="cx">             (optargs, args) = getopt(
</span><del>-                sys.argv[1:], &quot;Dd:b:f:hnv&quot;, [
</del><ins>+                sys.argv[1:], &quot;Dd:b:f:hnu:v&quot;, [
</ins><span class="cx">                     &quot;days=&quot;,
</span><span class="cx">                     &quot;batch=&quot;,
</span><span class="cx">                     &quot;dry-run&quot;,
</span><span class="cx">                     &quot;config=&quot;,
</span><ins>+                    &quot;uuid=&quot;,
</ins><span class="cx">                     &quot;help&quot;,
</span><span class="cx">                     &quot;verbose&quot;,
</span><span class="cx">                     &quot;debug&quot;,
</span><span class="lines">@@ -329,6 +333,7 @@
</span><span class="cx">         # Get configuration
</span><span class="cx">         #
</span><span class="cx">         configFileName = None
</span><ins>+        uuid = None
</ins><span class="cx">         days = DEFAULT_RETAIN_DAYS
</span><span class="cx">         batchSize = DEFAULT_BATCH_SIZE
</span><span class="cx">         dryrun = False
</span><span class="lines">@@ -365,12 +370,19 @@
</span><span class="cx">             elif opt in (&quot;-f&quot;, &quot;--config&quot;):
</span><span class="cx">                 configFileName = arg
</span><span class="cx"> 
</span><ins>+            elif opt in (&quot;-u&quot;, &quot;--uuid&quot;):
+                uuid = arg
+
</ins><span class="cx">             else:
</span><span class="cx">                 raise NotImplementedError(opt)
</span><span class="cx"> 
</span><span class="cx">         if args:
</span><span class="cx">             cls.usage(&quot;Too many arguments: %s&quot; % (args,))
</span><span class="cx"> 
</span><ins>+        if uuid is None:
+            cls.usage(&quot;uuid must be specified&quot;)
+        cls.uuid = uuid
+
</ins><span class="cx">         if dryrun:
</span><span class="cx">             verbose = True
</span><span class="cx"> 
</span><span class="lines">@@ -380,68 +392,321 @@
</span><span class="cx">         cls.cutoff = cutoff
</span><span class="cx">         cls.batchSize = batchSize
</span><span class="cx">         cls.dryrun = dryrun
</span><del>-        cls.verbose = verbose
</del><ins>+        cls.debug = debug
</ins><span class="cx"> 
</span><span class="cx">         utilityMain(
</span><span class="cx">             configFileName,
</span><span class="cx">             cls,
</span><del>-            verbose=debug,
</del><ins>+            verbose=verbose,
</ins><span class="cx">         )
</span><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx">     @classmethod
</span><span class="cx">     @inlineCallbacks
</span><del>-    def purgeOldEvents(cls, store, cutoff, batchSize, verbose=False, dryrun=False):
</del><ins>+    def purgeOldEvents(cls, store, uuid, cutoff, batchSize, debug=False, dryrun=False):
</ins><span class="cx"> 
</span><span class="cx">         service = cls(store)
</span><ins>+        service.uuid = uuid
</ins><span class="cx">         service.cutoff = cutoff
</span><span class="cx">         service.batchSize = batchSize
</span><span class="cx">         service.dryrun = dryrun
</span><del>-        service.verbose = verbose
</del><ins>+        service.debug = debug
</ins><span class="cx">         result = yield service.doWork()
</span><span class="cx">         returnValue(result)
</span><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx">     @inlineCallbacks
</span><ins>+    def getMatchingHomeUIDs(self):
+        &quot;&quot;&quot;
+        Find all the calendar homes that match the uuid cli argument.
+        &quot;&quot;&quot;
+        log.debug(&quot;Searching for calendar homes matching: '{}'&quot;.format(self.uuid))
+        txn = self.store.newTransaction(label=&quot;Find matching homes&quot;)
+        ch = schema.CALENDAR_HOME
+        if self.uuid:
+            kwds = {&quot;uuid&quot;: self.uuid}
+            rows = (yield Select(
+                [ch.RESOURCE_ID, ch.OWNER_UID, ],
+                From=ch,
+                Where=(ch.OWNER_UID.StartsWith(Parameter(&quot;uuid&quot;))),
+            ).on(txn, **kwds))
+        else:
+            rows = (yield Select(
+                [ch.RESOURCE_ID, ch.OWNER_UID, ],
+                From=ch,
+            ).on(txn))
+
+        yield txn.commit()
+        log.debug(&quot;  Found {} calendar homes&quot;.format(len(rows)))
+        returnValue(sorted(rows, key=lambda x: x[1]))
+
+
+    @inlineCallbacks
+    def getMatchingCalendarIDs(self, home_id, owner_uid):
+        &quot;&quot;&quot;
+        Find all the owned calendars for the specified calendar home.
+
+        @param home_id: resource-id of calendar home to check
+        @type home_id: L{int}
+        @param owner_uid: owner UUID of home to check
+        @type owner_uid: L{str}
+        &quot;&quot;&quot;
+        log.debug(&quot;Checking calendar home: {} '{}'&quot;.format(home_id, owner_uid))
+        txn = self.store.newTransaction(label=&quot;Find matching calendars&quot;)
+        cb = schema.CALENDAR_BIND
+        kwds = {&quot;home_id&quot;: home_id}
+        rows = (yield Select(
+            [cb.CALENDAR_RESOURCE_ID, cb.CALENDAR_RESOURCE_NAME, ],
+            From=cb,
+            Where=(cb.CALENDAR_HOME_RESOURCE_ID == Parameter(&quot;home_id&quot;)).And(
+                cb.BIND_MODE == _BIND_MODE_OWN
+            ),
+        ).on(txn, **kwds))
+        yield txn.commit()
+        log.debug(&quot;  Found {} calendars&quot;.format(len(rows)))
+        returnValue(rows)
+
+
+    PurgeEvent = collections.namedtuple(&quot;PurgeEvent&quot;, (&quot;home&quot;, &quot;calendar&quot;, &quot;resource&quot;,))
+
+    @inlineCallbacks
+    def getResourceIDsToPurge(self, home_id, calendar_id, calendar_name):
+        &quot;&quot;&quot;
+        For the given calendar find which calendar objects are older than the cut-off and return the
+        resource-ids of those.
+
+        @param home_id: resource-id of calendar home
+        @type home_id: L{int}
+        @param calendar_id: resource-id of the calendar to check
+        @type calendar_id: L{int}
+        @param calendar_name: name of the calendar to check
+        @type calendar_name: L{str}
+        &quot;&quot;&quot;
+
+        log.debug(&quot;  Checking calendar: {} '{}'&quot;.format(calendar_id, calendar_name))
+        purge = set()
+        txn = self.store.newTransaction(label=&quot;Find matching resources&quot;)
+        co = schema.CALENDAR_OBJECT
+        tr = schema.TIME_RANGE
+        kwds = {&quot;calendar_id&quot;: calendar_id}
+        rows = (yield Select(
+            [co.RESOURCE_ID, co.RECURRANCE_MAX, co.RECURRANCE_MIN, Max(tr.END_DATE)],
+            From=co.join(tr, on=(co.RESOURCE_ID == tr.CALENDAR_OBJECT_RESOURCE_ID)),
+            Where=(co.CALENDAR_RESOURCE_ID == Parameter(&quot;calendar_id&quot;)).And(
+                co.ICALENDAR_TYPE == &quot;VEVENT&quot;
+            ),
+            GroupBy=(co.RESOURCE_ID, co.RECURRANCE_MAX, co.RECURRANCE_MIN,),
+            Having=(
+                (co.RECURRANCE_MAX == None).And(Max(tr.END_DATE) &lt; pyCalendarToSQLTimestamp(self.cutoff))
+            ).Or(
+                (co.RECURRANCE_MAX != None).And(co.RECURRANCE_MAX &lt; pyCalendarToSQLTimestamp(self.cutoff))
+            ),
+        ).on(txn, **kwds))
+
+        log.debug(&quot;    Found {} resources to check&quot;.format(len(rows)))
+        for resource_id, recurrence_max, recurrence_min, max_end_date in rows:
+
+            recurrence_max = parseSQLDateToPyCalendar(recurrence_max) if recurrence_max else None
+            recurrence_min = parseSQLDateToPyCalendar(recurrence_min) if recurrence_min else None
+            max_end_date = parseSQLDateToPyCalendar(max_end_date) if max_end_date else None
+
+            # Find events where we know the max(end_date) represents a valid,
+            # untruncated expansion
+            if recurrence_min is None or recurrence_min &lt; self.cutoff:
+                if recurrence_max is None:
+                    # Here we know max_end_date is the fully expand final instance
+                    if max_end_date &lt; self.cutoff:
+                        purge.add(self.PurgeEvent(home_id, calendar_id, resource_id,))
+                    continue
+                elif recurrence_max &gt; self.cutoff:
+                    # Here we know that there are instances newer than the cut-off
+                    # but they have not yet been indexed out that far
+                    continue
+
+            # Manually detect the max_end_date from the actual calendar data
+            calendar = yield self.getCalendar(txn, resource_id)
+            if calendar is not None:
+                if self.checkLastInstance(calendar):
+                    purge.add(self.PurgeEvent(home_id, calendar_id, resource_id,))
+
+        yield txn.commit()
+        log.debug(&quot;    Found {} resources to purge&quot;.format(len(purge)))
+        returnValue(purge)
+
+
+    @inlineCallbacks
+    def getCalendar(self, txn, resid):
+        &quot;&quot;&quot;
+        Get the calendar data for a calendar object resource.
+
+        @param resid: resource-id of the calendar object resource to load
+        @type resid: L{int}
+        &quot;&quot;&quot;
+        co = schema.CALENDAR_OBJECT
+        kwds = {&quot;ResourceID&quot; : resid}
+        rows = (yield Select(
+            [co.ICALENDAR_TEXT],
+            From=co,
+            Where=(
+                co.RESOURCE_ID == Parameter(&quot;ResourceID&quot;)
+            ),
+        ).on(txn, **kwds))
+        try:
+            caldata = Component.fromString(rows[0][0]) if rows else None
+        except InvalidICalendarDataError:
+            returnValue(None)
+
+        returnValue(caldata)
+
+
+    def checkLastInstance(self, calendar):
+        &quot;&quot;&quot;
+        Determine the last instance of a calendar event. Try a &quot;static&quot; analysis of the data first,
+        and only if needed, do an instance expansion.
+
+        @param calendar: the calendar object to examine
+        @type calendar: L{Component}
+        &quot;&quot;&quot;
+
+        # Is it recurring
+        master = calendar.masterComponent()
+        if not calendar.isRecurring() or master is None:
+            # Just check the end date
+            for comp in calendar.subcomponents():
+                if comp.name() == &quot;VEVENT&quot;:
+                    if comp.getEndDateUTC() &gt; self.cutoff:
+                        return False
+            else:
+                return True
+        elif calendar.isRecurringUnbounded():
+            return False
+        else:
+            # First test all sub-components
+            # Just check the end date
+            for comp in calendar.subcomponents():
+                if comp.name() == &quot;VEVENT&quot;:
+                    if comp.getEndDateUTC() &gt; self.cutoff:
+                        return False
+
+            # If we get here we need to test the RRULE - if there is an until use
+            # that as the end point, if a count, we have to expand
+            rrules = tuple(master.properties(&quot;RRULE&quot;))
+            if len(rrules):
+                if rrules[0].value().getUseUntil():
+                    return rrules[0].value().getUntil() &lt; self.cutoff
+                else:
+                    return not calendar.hasInstancesAfter(self.cutoff)
+
+        return True
+
+
+    @inlineCallbacks
+    def getResourcesToPurge(self, home_id, owner_uid):
+        &quot;&quot;&quot;
+        Find all the resource-ids of calendar object resources that need to be purged in the specified home.
+
+        @param home_id: resource-id of calendar home to check
+        @type home_id: L{int}
+        @param owner_uid: owner UUID of home to check
+        @type owner_uid: L{str}
+        &quot;&quot;&quot;
+
+        purge = set()
+        calendars = yield self.getMatchingCalendarIDs(home_id, owner_uid)
+        for calendar_id, calendar_name in calendars:
+            purge.update((yield self.getResourceIDsToPurge(home_id, calendar_id, calendar_name)))
+
+        returnValue(purge)
+
+
+    @inlineCallbacks
+    def purgeResources(self, events):
+        &quot;&quot;&quot;
+        Remove up to batchSize events and return how
+        many were removed.
+        &quot;&quot;&quot;
+
+        txn = self.store.newTransaction(label=&quot;Remove old events&quot;)
+        count = 0
+        last_home = None
+        last_calendar = None
+        for event in events:
+            if event.home != last_home:
+                home = (yield txn.calendarHomeWithResourceID(event.home))
+                last_home = event.home
+            if event.calendar != last_calendar:
+                calendar = (yield home.childWithID(event.calendar))
+                last_calendar = event.calendar
+            resource = (yield calendar.objectResourceWithID(event.resource))
+            yield resource.purge(implicitly=False)
+            log.debug(&quot;Removed resource {} '{}' from calendar {} '{}' of calendar home '{}'&quot;.format(
+                resource.id(),
+                resource.name(),
+                resource.parentCollection().id(),
+                resource.parentCollection().name(),
+                resource.parentCollection().ownerHome().uid()
+            ))
+            count += 1
+        yield txn.commit()
+        returnValue(count)
+
+
+    @inlineCallbacks
</ins><span class="cx">     def doWork(self):
</span><span class="cx"> 
</span><ins>+        if self.debug:
+            # Turn on debug logging for this module
+            config.LogLevels[__name__] = &quot;debug&quot;
+        else:
+            config.LogLevels[__name__] = &quot;info&quot;
+        config.update()
+
+        homes = yield self.getMatchingHomeUIDs()
+        if not homes:
+            log.info(&quot;No homes to process&quot;)
+            returnValue(0)
+
</ins><span class="cx">         if self.dryrun:
</span><del>-            if self.verbose:
-                print(&quot;(Dry run) Searching for old events...&quot;)
-            txn = self.store.newTransaction(label=&quot;Find old events&quot;)
-            oldEvents = yield txn.eventsOlderThan(self.cutoff)
-            eventCount = len(oldEvents)
-            if self.verbose:
-                if eventCount == 0:
-                    print(&quot;No events are older than %s&quot; % (self.cutoff,))
-                elif eventCount == 1:
-                    print(&quot;1 event is older than %s&quot; % (self.cutoff,))
-                else:
-                    print(&quot;%d events are older than %s&quot; % (eventCount, self.cutoff))
</del><ins>+            log.info(&quot;Purge dry run only&quot;)
+
+        log.info(&quot;Searching for old events...&quot;)
+
+        purge = set()
+        homes = yield self.getMatchingHomeUIDs()
+        for home_id, owner_uid in homes:
+            purge.update((yield self.getResourcesToPurge(home_id, owner_uid)))
+
+        if self.dryrun:
+            eventCount = len(purge)
+            if eventCount == 0:
+                log.info(&quot;No events are older than %s&quot; % (self.cutoff,))
+            elif eventCount == 1:
+                log.info(&quot;1 event is older than %s&quot; % (self.cutoff,))
+            else:
+                log.info(&quot;%d events are older than %s&quot; % (eventCount, self.cutoff))
</ins><span class="cx">             returnValue(eventCount)
</span><span class="cx"> 
</span><del>-        if self.verbose:
-            print(&quot;Removing events older than %s...&quot; % (self.cutoff,))
</del><ins>+        purge = list(purge)
+        purge.sort()
+        totalEvents = len(purge)
</ins><span class="cx"> 
</span><ins>+        log.info(&quot;Removing {} events older than {}...&quot;.format(len(purge), self.cutoff,))
+
</ins><span class="cx">         numEventsRemoved = -1
</span><span class="cx">         totalRemoved = 0
</span><span class="cx">         while numEventsRemoved:
</span><del>-            txn = self.store.newTransaction(label=&quot;Remove old events&quot;)
-            numEventsRemoved = yield txn.removeOldEvents(self.cutoff, batchSize=self.batchSize)
-            yield txn.commit()
</del><ins>+            numEventsRemoved = (yield self.purgeResources(purge[:self.batchSize]))
</ins><span class="cx">             if numEventsRemoved:
</span><span class="cx">                 totalRemoved += numEventsRemoved
</span><del>-                if self.verbose:
-                    print(&quot;%d,&quot; % (totalRemoved,),)
</del><ins>+                log.debug(&quot;  Removed {} of {} events...&quot;.format(totalRemoved, totalEvents))
+                purge = purge[numEventsRemoved:]
</ins><span class="cx"> 
</span><del>-        if self.verbose:
-            print(&quot;&quot;)
-            if totalRemoved == 0:
-                print(&quot;No events were removed&quot;)
-            elif totalRemoved == 1:
-                print(&quot;1 event was removed in total&quot;)
-            else:
-                print(&quot;%d events were removed in total&quot; % (totalRemoved,))
</del><ins>+        if totalRemoved == 0:
+            log.info(&quot;No events were removed&quot;)
+        elif totalRemoved == 1:
+            log.info(&quot;1 event was removed in total&quot;)
+        else:
+            log.info(&quot;%d events were removed in total&quot; % (totalRemoved,))
</ins><span class="cx"> 
</span><span class="cx">         returnValue(totalRemoved)
</span><span class="cx"> 
</span><span class="lines">@@ -671,20 +936,18 @@
</span><span class="cx">             # Print table of results
</span><span class="cx">             table = tables.Table()
</span><span class="cx">             table.addHeader((&quot;User&quot;, &quot;Current Quota&quot;, &quot;Orphan Size&quot;, &quot;Orphan Count&quot;, &quot;Dropbox Size&quot;, &quot;Dropbox Count&quot;, &quot;Managed Size&quot;, &quot;Managed Count&quot;, &quot;Total Size&quot;, &quot;Total Count&quot;))
</span><del>-            table.setDefaultColumnFormats(
-                (
-                    tables.Table.ColumnFormat(&quot;%s&quot;, tables.Table.ColumnFormat.LEFT_JUSTIFY),
-                    tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
-                    tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
-                    tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
-                    tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
-                    tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
-                    tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
-                    tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
-                    tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
-                    tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
-                )
-            )
</del><ins>+            table.setDefaultColumnFormats((
+                tables.Table.ColumnFormat(&quot;%s&quot;, tables.Table.ColumnFormat.LEFT_JUSTIFY),
+                tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
+                tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
+                tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
+                tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
+                tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
+                tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
+                tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
+                tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
+                tables.Table.ColumnFormat(&quot;%d&quot;, tables.Table.ColumnFormat.RIGHT_JUSTIFY),
+            ))
</ins><span class="cx"> 
</span><span class="cx">             totals = [0] * 8
</span><span class="cx">             for user, data in sorted(byuser.items(), key=lambda x: x[0]):
</span></span></pre></div>
<a id="CalendarServertrunkcalendarservertoolstesttest_purge_old_eventspy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/calendarserver/tools/test/test_purge_old_events.py (14969 => 14970)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/calendarserver/tools/test/test_purge_old_events.py        2015-07-15 16:10:33 UTC (rev 14969)
+++ CalendarServer/trunk/calendarserver/tools/test/test_purge_old_events.py        2015-07-15 17:25:10 UTC (rev 14970)
</span><span class="lines">@@ -642,33 +642,123 @@
</span><span class="cx">         # Dry run
</span><span class="cx">         total = (yield PurgeOldEventsService.purgeOldEvents(
</span><span class="cx">             self._sqlCalendarStore,
</span><ins>+            None,
</ins><span class="cx">             DateTime(now, 4, 1, 0, 0, 0),
</span><span class="cx">             2,
</span><span class="cx">             dryrun=True,
</span><del>-            verbose=False
</del><ins>+            debug=True
</ins><span class="cx">         ))
</span><span class="cx">         self.assertEquals(total, 13)
</span><span class="cx"> 
</span><span class="cx">         # Actually remove
</span><span class="cx">         total = (yield PurgeOldEventsService.purgeOldEvents(
</span><span class="cx">             self._sqlCalendarStore,
</span><ins>+            None,
</ins><span class="cx">             DateTime(now, 4, 1, 0, 0, 0),
</span><span class="cx">             2,
</span><del>-            verbose=False
</del><ins>+            debug=True
</ins><span class="cx">         ))
</span><span class="cx">         self.assertEquals(total, 13)
</span><span class="cx"> 
</span><span class="cx">         # There should be no more left
</span><span class="cx">         total = (yield PurgeOldEventsService.purgeOldEvents(
</span><span class="cx">             self._sqlCalendarStore,
</span><ins>+            None,
</ins><span class="cx">             DateTime(now, 4, 1, 0, 0, 0),
</span><span class="cx">             2,
</span><del>-            verbose=False
</del><ins>+            debug=True
</ins><span class="cx">         ))
</span><span class="cx">         self.assertEquals(total, 0)
</span><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx">     @inlineCallbacks
</span><ins>+    def test_purgeOldEvents_home_filtering(self):
+
+        # Dry run
+        total = (yield PurgeOldEventsService.purgeOldEvents(
+            self._sqlCalendarStore,
+            &quot;ho&quot;,
+            DateTime(now, 4, 1, 0, 0, 0),
+            2,
+            dryrun=True,
+            debug=True
+        ))
+        self.assertEquals(total, 13)
+
+        # Dry run
+        total = (yield PurgeOldEventsService.purgeOldEvents(
+            self._sqlCalendarStore,
+            &quot;home&quot;,
+            DateTime(now, 4, 1, 0, 0, 0),
+            2,
+            dryrun=True,
+            debug=True
+        ))
+        self.assertEquals(total, 13)
+
+        # Dry run
+        total = (yield PurgeOldEventsService.purgeOldEvents(
+            self._sqlCalendarStore,
+            &quot;home1&quot;,
+            DateTime(now, 4, 1, 0, 0, 0),
+            2,
+            dryrun=True,
+            debug=True
+        ))
+        self.assertEquals(total, 5)
+
+        # Dry run
+        total = (yield PurgeOldEventsService.purgeOldEvents(
+            self._sqlCalendarStore,
+            &quot;home2&quot;,
+            DateTime(now, 4, 1, 0, 0, 0),
+            2,
+            dryrun=True,
+            debug=True
+        ))
+        self.assertEquals(total, 8)
+
+
+    @inlineCallbacks
+    def test_purgeOldEvents_old_cutoff(self):
+
+        # Dry run
+        cutoff = DateTime.getToday()
+        cutoff.setDateOnly(False)
+        cutoff.offsetDay(-400)
+
+        total = (yield PurgeOldEventsService.purgeOldEvents(
+            self._sqlCalendarStore,
+            &quot;ho&quot;,
+            cutoff,
+            2,
+            dryrun=True,
+            debug=True
+        ))
+        self.assertEquals(total, 12)
+
+        # Actually remove
+        total = (yield PurgeOldEventsService.purgeOldEvents(
+            self._sqlCalendarStore,
+            None,
+            cutoff,
+            2,
+            debug=True
+        ))
+        self.assertEquals(total, 12)
+
+        total = (yield PurgeOldEventsService.purgeOldEvents(
+            self._sqlCalendarStore,
+            &quot;ho&quot;,
+            cutoff,
+            2,
+            dryrun=True,
+            debug=True
+        ))
+        self.assertEquals(total, 0)
+
+
+    @inlineCallbacks
</ins><span class="cx">     def test_purgeUID(self):
</span><span class="cx">         txn = self._sqlCalendarStore.newTransaction()
</span><span class="cx"> 
</span><span class="lines">@@ -753,9 +843,10 @@
</span><span class="cx">         # Remove old events first
</span><span class="cx">         total = (yield PurgeOldEventsService.purgeOldEvents(
</span><span class="cx">             self._sqlCalendarStore,
</span><ins>+            None,
</ins><span class="cx">             DateTime(now, 4, 1, 0, 0, 0),
</span><span class="cx">             2,
</span><del>-            verbose=False
</del><ins>+            debug=False
</ins><span class="cx">         ))
</span><span class="cx">         self.assertEquals(total, 13)
</span><span class="cx"> 
</span></span></pre>
</div>
</div>

</body>
</html>