[CalendarServer-changes] [12459] CalendarServer/branches/users/cdaboo/scheduling-queue-refresh

source_changes at macosforge.org source_changes at macosforge.org
Wed Mar 12 11:22:10 PDT 2014


Revision: 12459
          http://trac.calendarserver.org//changeset/12459
Author:   cdaboo at apple.com
Date:     2014-01-27 17:25:30 -0800 (Mon, 27 Jan 2014)
Log Message:
-----------
Checkpoint: changed iTIP message processing to treat the attendee partstat state as definitive when sequence has not changed. This
ensures out of sequence request/replies do not mess up valid attendee states.

Modified Paths:
--------------
    CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/conf/caldavd-test.plist
    CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/twistedcaldav/stdconfig.py
    CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/icaldiff.py
    CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/implicit.py
    CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/itip.py
    CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/processing.py
    CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/test/test_icaldiff.py
    CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/test/test_implicit.py
    CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/test/test_itip.py
    CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/work.py

Modified: CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/conf/caldavd-test.plist
===================================================================
--- CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/conf/caldavd-test.plist	2014-01-28 00:07:43 UTC (rev 12458)
+++ CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/conf/caldavd-test.plist	2014-01-28 01:25:30 UTC (rev 12459)
@@ -792,6 +792,22 @@
 	        <key>DefaultMode</key>
 	        <string>automatic</string>
 		</dict>
+		
+		<key>WorkQueues</key>
+		<dict>
+			<key>Enabled</key>
+			<false/>
+            <key>RequestDelaySeconds</key>
+            <integer>1</integer>
+            <key>ReplyDelaySeconds</key>
+            <integer>2</integer>
+            <key>AutoReplyDelaySeconds</key>
+            <integer>1</integer>
+            <key>AttendeeRefreshBatchDelaySeconds</key>
+            <integer>2</integer>
+            <key>AttendeeRefreshBatchIntervalSeconds</key>
+            <integer>2</integer>
+		</dict>
       </dict>
     </dict>
 

Modified: CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/twistedcaldav/stdconfig.py
===================================================================
--- CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/twistedcaldav/stdconfig.py	2014-01-28 00:07:43 UTC (rev 12458)
+++ CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/twistedcaldav/stdconfig.py	2014-01-28 01:25:30 UTC (rev 12459)
@@ -737,12 +737,7 @@
             "TrackUnscheduledResourceData"        : True, # Track who the last modifier of an unscheduled resource event is
             "LimitFreeBusyAttendees"              : 30, # Maximum number of attendees to request freebusy for
             "AttendeeRefreshBatch"                : 5, # Number of attendees to do batched refreshes: 0 - no batching
-            "AttendeeRefreshBatchDelaySeconds"    : 5, # Time after an iTIP REPLY for first batched attendee refresh
-            "AttendeeRefreshBatchIntervalSeconds" : 5, # Time between attendee batch refreshes
             "AttendeeRefreshCountLimit"           : 50, # Number of attendees above which attendee refreshes are suppressed: 0 - no limit
-            "AutoReplyDelaySeconds"               : 5, # Time delay for sending an auto reply iTIP message
-            "QueuedRequestDelaySeconds"           : 5, # Number of seconds delay for a queued scheduling request/cancel
-            "QueuedReplyDelaySeconds"             : 1, # Number of seconds delay for a queued scheduling reply
             "UIDLockTimeoutSeconds"               : 60, # Time for implicit UID lock timeout
             "UIDLockExpirySeconds"                : 300, # Expiration time for UID lock,
             "PrincipalHostAliases"                : [], # Host names matched in http(s) CUAs
@@ -765,6 +760,15 @@
                 "FutureFreeBusyDays"              : 3 * 365,       # How far into the future to check for booking conflicts
             },
 
+            "WorkQueues" : {
+                "Enabled"                             : False,       # Work queues for scheduling enabled
+                "RequestDelaySeconds"                 : 5,          # Number of seconds delay for a queued scheduling request/cancel
+                "ReplyDelaySeconds"                   : 1,          # Number of seconds delay for a queued scheduling reply
+                "AutoReplyDelaySeconds"               : 5,          # Time delay for sending an auto reply iTIP message
+                "AttendeeRefreshBatchDelaySeconds"    : 5,          # Time after an iTIP REPLY for first batched attendee refresh
+                "AttendeeRefreshBatchIntervalSeconds" : 5,          # Time between attendee batch refreshes
+            },
+
             "Splitting": {
                 "Enabled"                         : False,          # False for now whilst we experiment with this
                 "Size"                            : 100 * 1024,     # Consider splitting when greater than 100KB

Modified: CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/icaldiff.py
===================================================================
--- CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/icaldiff.py	2014-01-28 00:07:43 UTC (rev 12458)
+++ CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/icaldiff.py	2014-01-28 01:25:30 UTC (rev 12459)
@@ -774,6 +774,75 @@
         return rids
 
 
+    def attendeeNeedsAction(self, diffs):
+        """
+        Given a set of results from L{whatIsDifferent}, determine which recurrence-id's
+        have changed in such a way that we need to force the attendee to NEEDS-ACTION
+        state. We need this because with async processing of iTIP messages, it is possible
+        that the incoming iTIP message does not gave the most recent partstat change an
+        attendee made, but we need to preserve the attendee partstat whenever possible.
+
+        @param diffs: mapping of rid to property changes
+        @type diffs: C{dict}
+        """
+
+        date_changed_rids = set()
+        recurrence_reschedule = False
+
+        for rid, props in diffs.iteritems():
+            if any([testprop in props for testprop in (
+                "DTSTART",
+                "DTEND",
+                "DURATION",
+                "DUE",
+                "RECURRENCE-ID",
+            )]):
+                date_changed_rids.add(rid)
+
+            # Check to see whether a change to R-ID's happened
+            if rid is None:
+
+                if "DTSTART" in props and self.newcalendar.masterComponent().hasProperty("RRULE"):
+                    # DTSTART change with RRULE present is always a reschedule
+                    recurrence_reschedule = True
+
+                elif "RRULE" in props:
+
+                    # Need to see if the RRULE change is a simple truncation or expansion - i.e. a change to
+                    # COUNT or UNTIL only. If so we don't need to treat this as a complete re-schedule.
+
+                    # Start off assuming they are different
+                    recurrence_reschedule = True
+
+                    # Get each RRULE (can be only one in the master)
+                    oldrrule = tuple(self.oldcalendar.masterComponent().properties("RRULE"))
+                    oldrrule = oldrrule[0].value() if len(oldrrule) else None
+                    newrrule = tuple(self.newcalendar.masterComponent().properties("RRULE"))
+                    newrrule = newrrule[0].value() if len(newrrule) else None
+
+                    if newrrule is not None and oldrrule is not None:
+
+                        # Normalize the rrules by removing COUNT/UNTIL and then compare
+                        oldrrule = oldrrule.duplicate()
+                        newrrule = newrrule.duplicate()
+
+                        oldrrule.setUseUntil(False)
+                        oldrrule.setUntil(None)
+                        oldrrule.setUseCount(False)
+                        oldrrule.setCount(0)
+
+                        newrrule.setUseUntil(False)
+                        newrrule.setUntil(None)
+                        newrrule.setUseCount(False)
+                        newrrule.setCount(0)
+
+                        # If they are equal we have a simple change - no overall reschedule
+                        if newrrule == oldrrule:
+                            recurrence_reschedule = False
+
+        return (date_changed_rids, recurrence_reschedule,)
+
+
     def _componentDuplicateAndNormalize(self, comp):
         comp = comp.duplicate()
         comp.normalizePropertyValueLists("EXDATE")

Modified: CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/implicit.py
===================================================================
--- CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/implicit.py	2014-01-28 00:07:43 UTC (rev 12458)
+++ CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/implicit.py	2014-01-28 01:25:30 UTC (rev 12459)
@@ -596,7 +596,7 @@
     @inlineCallbacks
     def doImplicitOrganizer(self, queued=False):
 
-        if not queued:
+        if not queued or not config.Scheduling.Options.WorkQueues.Enabled:
             self.oldcalendar = None
         self.changed_rids = None
         self.cancelledAttendees = ()
@@ -615,21 +615,21 @@
             self.cancelledAttendees = [(attendee, None) for attendee in self.attendees]
 
             # CANCEL always bumps sequence
-            if not queued:
+            if not queued or not config.Scheduling.Options.WorkQueues.Enabled:
                 self.needs_sequence_change = True
 
         # Check for a new resource or an update
         elif self.action == "modify":
 
             # Read in existing data
-            if not queued:
+            if not queued or not config.Scheduling.Options.WorkQueues.Enabled:
                 self.oldcalendar = (yield self.resource.componentForUser())
             self.oldAttendeesByInstance = self.oldcalendar.getAttendeesByInstance(True, onlyScheduleAgentServer=True)
             self.oldInstances = set(self.oldcalendar.getComponentInstances())
             self.coerceAttendeesPartstatOnModify()
 
             # Don't allow any SEQUENCE to decrease
-            if self.oldcalendar and not queued:
+            if self.oldcalendar and (not queued or not config.Scheduling.Options.WorkQueues.Enabled):
                 self.calendar.sequenceInSync(self.oldcalendar)
 
             # Significant change
@@ -678,7 +678,7 @@
 
                 # For now we always bump the sequence number on modifications because we cannot track DTSTAMP on
                 # the Attendee side. But we check the old and the new and only bump if the client did not already do it.
-                if not queued:
+                if not queued or not config.Scheduling.Options.WorkQueues.Enabled:
                     self.needs_sequence_change = self.calendar.needsiTIPSequenceChange(self.oldcalendar)
 
         elif self.action == "create":
@@ -699,7 +699,7 @@
 
         # If processing a queue item, actually execute the scheduling operations, else queue it.
         # Note a split is always queued, so we do not need to re-queue
-        if queued or self.split_details is not None:
+        if queued or not config.Scheduling.Options.WorkQueues.Enabled or self.split_details is not None:
             yield self.scheduleWithAttendees()
         else:
             yield self.queuedScheduleWithAttendees()
@@ -1550,22 +1550,24 @@
         if self.logItems is not None:
             self.logItems["itip.reply"] = "reply"
 
-#        itipmsg = iTipGenerator.generateAttendeeReply(self.calendar, self.attendee, changedRids=changedRids)
-#
-#        # Send scheduling message
-#        return self.sendToOrganizer("REPLY", itipmsg)
+        if config.Scheduling.Options.WorkQueues.Enabled:
+            # Always make it look like scheduling succeeded when queuing
+            self.calendar.setParameterToValueForPropertyWithValue(
+                "SCHEDULE-STATUS",
+                iTIPRequestStatus.MESSAGE_DELIVERED_CODE,
+                "ORGANIZER",
+                self.organizer,
+            )
 
-        # Always make it look like scheduling succeeded when queuing
-        self.calendar.setParameterToValueForPropertyWithValue(
-            "SCHEDULE-STATUS",
-            iTIPRequestStatus.MESSAGE_DELIVERED_CODE,
-            "ORGANIZER",
-            self.organizer,
-        )
+            return ScheduleReplyWork.reply(self.txn, self.calendar_home, self.resource, changedRids, self.attendee)
 
-        return ScheduleReplyWork.reply(self.txn, self.calendar_home, self.resource, changedRids, self.attendee)
+        else:
+            itipmsg = iTipGenerator.generateAttendeeReply(self.calendar, self.attendee, changedRids=changedRids)
 
+            # Send scheduling message
+            return self.sendToOrganizer("REPLY", itipmsg)
 
+
     def scheduleCancelWithOrganizer(self):
 
         # First make sure we are allowed to schedule
@@ -1574,14 +1576,16 @@
         if self.logItems is not None:
             self.logItems["itip.reply"] = "cancel"
 
-#        itipmsg = iTipGenerator.generateAttendeeReply(self.calendar, self.attendee, force_decline=True)
-#
-#        # Send scheduling message
-#        return self.sendToOrganizer("CANCEL", itipmsg)
+        if config.Scheduling.Options.WorkQueues.Enabled:
+            return ScheduleReplyCancelWork.replyCancel(self.txn, self.calendar_home, self.calendar, self.attendee)
 
-        return ScheduleReplyCancelWork.replyCancel(self.txn, self.calendar_home, self.calendar, self.attendee)
+        else:
+            itipmsg = iTipGenerator.generateAttendeeReply(self.calendar, self.attendee, force_decline=True)
 
+            # Send scheduling message
+            return self.sendToOrganizer("CANCEL", itipmsg)
 
+
     @inlineCallbacks
     def sendToOrganizer(self, action, itipmsg):
 

Modified: CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/itip.py
===================================================================
--- CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/itip.py	2014-01-28 00:07:43 UTC (rev 12458)
+++ CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/itip.py	2014-01-28 01:25:30 UTC (rev 12459)
@@ -67,10 +67,11 @@
             calendar.removeProperty(method)
 
         if recipient:
-            iTipProcessing.addTranspForNeedsAction(calendar.subcomponents(), recipient)
 
             # Check for incoming DECLINED
             if creating:
+                iTipProcessing.addTranspForNeedsAction(calendar.subcomponents(), recipient)
+
                 master = calendar.masterComponent()
                 for component in tuple(calendar.subcomponents()):
                     if component in ignoredComponents or component is master:
@@ -111,14 +112,16 @@
 
         # Merge Organizer data with Attendee's own changes (VALARMs, Comment only for now).
         from txdav.caldav.datastore.scheduling.icaldiff import iCalDiff
-        rids = iCalDiff(calendar, itip_message, False).whatIsDifferent()
+        differ = iCalDiff(calendar, itip_message, False)
+        rids = differ.whatIsDifferent()
+        needs_action_rids, reschedule = differ.attendeeNeedsAction(rids)
 
         # Different behavior depending on whether a master component is present or not
         # Here we cache per-attendee data from the existing master that we need to use in any new
         # overridden components that the organizer added
         current_master = calendar.masterComponent()
         if current_master:
-            master_valarms = [comp for comp in current_master.subcomponents() if comp.name() == "VALARM"]
+            valarms = [comp for comp in current_master.subcomponents() if comp.name() == "VALARM"]
             private_comments = current_master.properties("X-CALENDARSERVER-PRIVATE-COMMENT")
             transps = current_master.properties("TRANSP")
             completeds = current_master.properties("COMPLETED")
@@ -132,11 +135,12 @@
                 if props:
                     other_props[pname] = props
         else:
-            master_valarms = ()
+            valarms = ()
             private_comments = ()
             transps = ()
             completeds = ()
             organizer_schedule_status = None
+            attendee = None
             attendee_dtstamp = None
             other_props = {}
 
@@ -145,31 +149,16 @@
             # Get a new calendar object first
             new_calendar = iTipProcessing.processNewRequest(itip_message, recipient)
 
-            # Copy over master alarms, comments
+            # Copy over master alarms, comments etc
             master_component = new_calendar.masterComponent()
-            for alarm in master_valarms:
-                master_component.addComponent(alarm)
-            for comment in private_comments:
-                master_component.addProperty(comment)
-            for transp in transps:
-                master_component.replaceProperty(transp)
-            for completed in completeds:
-                master_component.replaceProperty(completed)
-            if organizer_schedule_status:
-                organizer = master_component.getProperty("ORGANIZER")
-                if organizer:
-                    organizer.setParameter("SCHEDULE-STATUS", organizer_schedule_status)
-            if attendee_dtstamp:
-                attendee = master_component.getAttendeeProperty((recipient,))
-                if attendee:
-                    attendee.setParameter("X-CALENDARSERVER-DTSTAMP", attendee_dtstamp)
-            for props in other_props.values():
-                [master_component.replaceProperty(prop) for prop in props]
+            transfer_partstat = None not in needs_action_rids and not reschedule
+            seq_change = Component.compareComponentsForITIP(master_component, current_master, use_dtstamp=False) <= 0
+            iTipProcessing._transferItems(master_component, transfer_partstat and seq_change, valarms, private_comments, transps, completeds, organizer_schedule_status, attendee, attendee_dtstamp, other_props, recipient)
 
             # Now try to match recurrences in the new calendar
             for component in tuple(new_calendar.subcomponents()):
                 if component.name() != "VTIMEZONE" and component.getRecurrenceIDUTC() is not None:
-                    iTipProcessing.transferItems(calendar, component, master_valarms, private_comments, transps, completeds, organizer_schedule_status, attendee_dtstamp, other_props, recipient)
+                    iTipProcessing.transferItems(calendar, component, needs_action_rids, reschedule, valarms, private_comments, transps, completeds, organizer_schedule_status, attendee, attendee_dtstamp, other_props, recipient)
 
             # Now try to match recurrences from the old calendar
             for component in calendar.subcomponents():
@@ -181,10 +170,12 @@
                         new_component = new_calendar.deriveInstance(rid, allowCancelled=allowCancelled and not hidden)
                         if new_component is not None:
                             new_calendar.addComponent(new_component)
-                            iTipProcessing.transferItems(calendar, new_component, master_valarms, private_comments, transps, completeds, organizer_schedule_status, attendee_dtstamp, other_props, recipient)
+                            iTipProcessing.transferItems(calendar, new_component, needs_action_rids, reschedule, valarms, private_comments, transps, completeds, organizer_schedule_status, attendee, attendee_dtstamp, other_props, recipient)
                             if hidden:
                                 new_component.addProperty(Property(Component.HIDDEN_INSTANCE_PROPERTY, "T"))
 
+            iTipProcessing.addTranspForNeedsAction(new_calendar.subcomponents(), recipient)
+
             # Replace the entire object
             return new_calendar, rids
 
@@ -200,12 +191,12 @@
                         calendar.addComponent(component)
                 else:
                     component = component.duplicate()
-                    missingDeclined = iTipProcessing.transferItems(calendar, component, master_valarms, private_comments, transps, completeds, organizer_schedule_status, attendee_dtstamp, other_props, recipient, remove_matched=True)
+                    missingDeclined = iTipProcessing.transferItems(calendar, component, needs_action_rids, reschedule, valarms, private_comments, transps, completeds, organizer_schedule_status, attendee, attendee_dtstamp, other_props, recipient, remove_matched=True)
                     if not missingDeclined:
                         calendar.addComponent(component)
-                        if recipient:
-                            iTipProcessing.addTranspForNeedsAction((component,), recipient)
 
+            iTipProcessing.addTranspForNeedsAction(calendar.subcomponents(), recipient)
+
             # Write back the modified object
             return calendar, rids
 
@@ -558,7 +549,7 @@
 
 
     @staticmethod
-    def transferItems(from_calendar, to_component, master_valarms, private_comments, transps, completeds, organizer_schedule_status, attendee_dtstamp, other_props, recipient, remove_matched=False):
+    def transferItems(from_calendar, to_component, needs_action_rids, reschedule, valarms, private_comments, transps, completeds, organizer_schedule_status, attendee, attendee_dtstamp, other_props, recipient, remove_matched=False):
         """
         Transfer properties from a calendar to a component by first trying to match the component in the original calendar and
         use the properties from that, or use the values provided as arguments (which have been derived from the original calendar's
@@ -568,8 +559,8 @@
         @type from_calendar: L{Component}
         @param to_component: the new component to transfer items to
         @type to_component: L{Component}
-        @param master_valarms: a C{list} of VALARM components from the old master to use
-        @type master_valarms: C{list}
+        @param valarms: a C{list} of VALARM components from the old master to use
+        @type valarms: C{list}
         @param private_comments: a C{list} of private comment properties from the old master to use
         @type private_comments: C{list}
         @param transps: a C{list} of TRANSP properties from the old master to use
@@ -592,44 +583,43 @@
 
         rid = to_component.getRecurrenceIDUTC()
 
+        transfer_partstat = rid not in needs_action_rids and not reschedule
+
         # Is there a matching component
         matched = from_calendar.overriddenComponent(rid)
         if matched:
-            # Copy over VALARMs from existing component
-            [to_component.addComponent(comp) for comp in matched.subcomponents() if comp.name() == "VALARM"]
-            [to_component.addProperty(prop) for prop in matched.properties("X-CALENDARSERVER-ATTENDEE-COMMENT")]
-            [to_component.replaceProperty(prop) for prop in matched.properties("TRANSP")]
-            [to_component.replaceProperty(prop) for prop in matched.properties("COMPLETED")]
-
+            valarms = [comp for comp in matched.subcomponents() if comp.name() == "VALARM"]
+            private_comments = matched.properties("X-CALENDARSERVER-PRIVATE-COMMENT")
+            transps = matched.properties("TRANSP")
+            completeds = matched.properties("COMPLETED")
             organizer = matched.getProperty("ORGANIZER")
             organizer_schedule_status = organizer.parameterValue("SCHEDULE-STATUS", None) if organizer else None
-            if organizer_schedule_status:
-                organizer = to_component.getProperty("ORGANIZER")
-                if organizer:
-                    organizer.setParameter("SCHEDULE-STATUS", organizer_schedule_status)
+            attendee = matched.getAttendeeProperty((recipient,))
+            attendee_dtstamp = attendee.parameterValue("X-CALENDARSERVER-DTSTAMP") if attendee else None
+            other_props = {}
+            for pname in config.Scheduling.CalDAV.PerAttendeeProperties:
+                props = tuple(matched.properties(pname))
+                if props:
+                    other_props[pname] = props
 
-            # Remove the old one
-            if remove_matched:
-                from_calendar.removeComponent(matched)
+            seq_change = Component.compareComponentsForITIP(to_component, matched, use_dtstamp=False) <= 0
+            iTipProcessing._transferItems(to_component, transfer_partstat and seq_change, valarms, private_comments, transps, completeds, organizer_schedule_status, attendee, attendee_dtstamp, other_props, recipient)
 
             # Check for incoming DECLINED
-            attendee = to_component.getAttendeeProperty((recipient,))
-            if attendee and attendee.parameterValue("PARTSTAT", "NEEDS-ACTION") == "DECLINED":
+            to_attendee = to_component.getAttendeeProperty((recipient,))
+            if to_attendee and to_attendee.parameterValue("PARTSTAT", "NEEDS-ACTION") == "DECLINED":
                 # If existing item has HIDDEN property copy that over
                 if matched.hasProperty(Component.HIDDEN_INSTANCE_PROPERTY):
                     to_component.addProperty(Property(Component.HIDDEN_INSTANCE_PROPERTY, "T"))
 
-            if attendee and attendee_dtstamp:
-                attendee.setParameter("X-CALENDARSERVER-DTSTAMP", attendee_dtstamp)
+            # Remove the old one
+            if remove_matched:
+                from_calendar.removeComponent(matched)
 
-            for pname in config.Scheduling.CalDAV.PerAttendeeProperties:
-                [to_component.replaceProperty(prop) for prop in matched.properties(pname)]
-
             # Check to see if the new component is cancelled as that could mean we are copying in the wrong attendee state
             if to_component.propertyValue("STATUS") == "CANCELLED":
-                from_attendee = matched.getAttendeeProperty((recipient,))
-                if attendee and from_attendee:
-                    attendee.setParameter("PARTSTAT", from_attendee.parameterValue("PARTSTAT", "NEEDS-ACTION"))
+                if attendee and to_attendee:
+                    to_attendee.setParameter("PARTSTAT", attendee.parameterValue("PARTSTAT", "NEEDS-ACTION"))
 
         else:
             # Check for incoming DECLINED
@@ -637,29 +627,89 @@
             if attendee and attendee.parameterValue("PARTSTAT", "NEEDS-ACTION") == "DECLINED":
                 return True
 
-            # It is a new override - copy any valarms on the existing master component
-            # into the new one.
-            [to_component.addComponent(alarm) for alarm in master_valarms]
-            [to_component.addProperty(comment) for comment in private_comments]
-            [to_component.replaceProperty(transp) for transp in transps]
-            [to_component.replaceProperty(completed) for completed in completeds]
+            master_component = from_calendar.masterComponent()
+            seq_change = (Component.compareComponentsForITIP(to_component, master_component, use_dtstamp=False) <= 0) if master_component is not None else True
+            iTipProcessing._transferItems(to_component, transfer_partstat and seq_change, valarms, private_comments, transps, completeds, organizer_schedule_status, attendee, attendee_dtstamp, other_props, recipient)
 
-            if organizer_schedule_status:
-                organizer = to_component.getProperty("ORGANIZER")
-                if organizer:
-                    organizer.setParameter("SCHEDULE-STATUS", organizer_schedule_status)
-            if attendee_dtstamp:
-                attendee = to_component.getAttendeeProperty((recipient,))
-                if attendee:
-                    attendee.setParameter("X-CALENDARSERVER-DTSTAMP", attendee_dtstamp)
+        return False
 
-            for props in other_props.values():
-                [to_component.replaceProperty(prop) for prop in props]
 
+    @staticmethod
+    def _transferItems(to_component, transfer_partstat, valarms, private_comments, transps, completeds, organizer_schedule_status, old_attendee, attendee_dtstamp, other_props, recipient):
+        """
+        Transfer properties the key per-attendee properties from one component to another. Note that the key properties are pulled out into separate items, because they
+        may have been derived from the master.
+
+        @param to_component: the new component to transfer items to
+        @type to_component: L{Component}
+        @param partstat_change: whether not to transfer the old PARTSTAT over
+        @type partstat_change: C{bool}
+        @param valarms: a C{list} of VALARM components from the old master to use
+        @type valarms: C{list}
+        @param private_comments: a C{list} of private comment properties from the old master to use
+        @type private_comments: C{list}
+        @param transps: a C{list} of TRANSP properties from the old master to use
+        @type transps: C{list}
+        @param completeds: a C{list} of COMPLETED properties from the old master to use
+        @type completeds: C{list}
+        @param organizer_schedule_status: a the SCHEDULE-STATUS value for the organizer from the old master to use
+        @type organizer_schedule_status: C{str}
+        @param attendee_dtstamp: an the ATTENDEE DTSTAMP parameter value from the old master to use
+        @type attendee_dtstamp: C{str}
+        @param other_props: other properties from the old master to use
+        @type other_props: C{list}
+        @param recipient: the calendar user address of the attendee whose data is being processed
+        @type recipient: C{str}
+
+        @return: C{True} if an EXDATE match occurred requiring the incoming component to be removed.
+        """
+
+        # It is a new override - copy any valarms on the existing master component
+        # into the new one.
+        [to_component.addComponent(alarm) for alarm in valarms]
+        [to_component.addProperty(comment) for comment in private_comments]
+        [to_component.replaceProperty(transp) for transp in transps]
+        [to_component.replaceProperty(completed) for completed in completeds]
+
+        if organizer_schedule_status:
+            organizer = to_component.getProperty("ORGANIZER")
+            if organizer:
+                organizer.setParameter("SCHEDULE-STATUS", organizer_schedule_status)
+
+        # ATTENDEE property merge
+        attendee = to_component.getAttendeeProperty((recipient,))
+        if old_attendee and attendee and transfer_partstat:
+            iTipProcessing.mergePartStat(old_attendee, attendee)
+
+        if attendee_dtstamp and attendee:
+            attendee.setParameter("X-CALENDARSERVER-DTSTAMP", attendee_dtstamp)
+
+        for props in other_props.values():
+            [to_component.replaceProperty(prop) for prop in props]
+
         return False
 
 
     @staticmethod
+    def mergePartStat(from_attendee, to_attendee):
+        """
+        Make sure the existing attendee PARTSTAT is preserved and also get rid of any RSVP
+        if the new PARTSTAT is not NEEDS-ACTION.
+
+        @param from_attendee: attendee property to copy PARTSTAT from
+        @type from_attendee: L{twistedcaldav.ical.Property}
+        @param to_attendee: attendee property to copy PARTSTAT to
+        @type to_attendee: L{twistedcaldav.ical.Property}
+        """
+
+        preserve = from_attendee.parameterValue("PARTSTAT", "NEEDS-ACTION")
+        if preserve != to_attendee.parameterValue("PARTSTAT", "NEEDS-ACTION"):
+            to_attendee.setParameter("PARTSTAT", preserve)
+        if preserve != "NEEDS-ACTION":
+            to_attendee.removeParameter("RSVP")
+
+
+    @staticmethod
     def addTranspForNeedsAction(components, recipient):
         """
         For each component where the ATTENDEE property of the recipient has PARTSTAT

Modified: CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/processing.py
===================================================================
--- CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/processing.py	2014-01-28 00:07:43 UTC (rev 12458)
+++ CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/processing.py	2014-01-28 01:25:30 UTC (rev 12459)
@@ -77,12 +77,12 @@
         Do implicit processing of a scheduling message, and possibly also auto-process it
         if the recipient has auto-accept on.
 
-        @param message:
-        @type message:
-        @param originator:
-        @type originator:
-        @param recipient:
-        @type recipient:
+        @param message: the iTIP message
+        @type message: L{twistedcaldav.ical.Component}
+        @param originator: calendar user sending the message
+        @type originator: C{str}
+        @param recipient: calendar user receiving the message
+        @type recipient: C{str}
 
         @return: a C{tuple} of (C{bool}, C{bool}) indicating whether the message was processed, and if it was whether
             auto-processing has taken place.
@@ -164,6 +164,9 @@
 
     @inlineCallbacks
     def doImplicitOrganizer(self):
+        """
+        Process an iTIP message sent to the organizer.
+        """
 
         # Locate the organizer's copy of the event.
         yield self.getRecipientsCopy()
@@ -184,8 +187,12 @@
 
     @inlineCallbacks
     def doImplicitOrganizerUpdate(self):
+        """
+        An iTIP REPLY has been sent by an attendee to an organizer and the attendee state needs to be sync'd
+        to the organizer's copy of the event.
+        """
 
-        # Check to see if this is a valid reply
+        # Check to see if this is a valid reply - this will also merge the changes to the organizer's copy
         result, processed = iTipProcessing.processReply(self.message, self.recipient_calendar)
         if result:
 
@@ -247,7 +254,7 @@
     @inlineCallbacks
     def queueAttendeeUpdate(self, exclude_attendees):
         """
-        Queue up an update to attendees and use a memcache lock to ensure we don't update too frequently.
+        Queue up a background update to attendees.
 
         @param exclude_attendees: list of attendees who should not be refreshed (e.g., the one that triggered the refresh)
         @type exclude_attendees: C{list}
@@ -307,12 +314,16 @@
 
     @inlineCallbacks
     def doImplicitAttendee(self):
+        """
+        Process an iTIP message sent to an attendee.
+        """
 
         # Locate the attendee's copy of the event if it exists.
         yield self.getRecipientsCopy()
         self.new_resource = self.recipient_calendar is None
 
-        # Handle new items differently than existing ones.
+        # If we get a CANCEL and we don't have a matching resource already stored, simply
+        # ignore the CANCEL.
         if self.new_resource and self.method == "CANCEL":
             result = (True, True, False, None)
         else:
@@ -323,6 +334,10 @@
 
     @inlineCallbacks
     def doImplicitAttendeeUpdate(self):
+        """
+        An iTIP message has been sent by to an attendee by the organizer. We need to update the attendee state
+        based on the nature of the iTIP message.
+        """
 
         # Do security check: ORGANZIER in iTIP MUST match existing resource value
         if self.recipient_calendar:
@@ -388,6 +403,10 @@
     @inlineCallbacks
     def doImplicitAttendeeRequest(self):
         """
+        An iTIP REQUEST message has been sent to an attendee. If there is no existing resource, we will simply
+        create a new one. If there is an existing resource we need to reconcile the changes between it and the
+        iTIP message.
+
         @return: C{tuple} of (processed, auto-processed, store inbox item, changes)
         """
 
@@ -508,11 +527,18 @@
 
     @inlineCallbacks
     def doImplicitAttendeeCancel(self):
+        """
+        An iTIP CANCEL message has been sent to an attendee. If there is no existing resource, we will simply
+        ignore the message. If there is an existing resource we need to reconcile the changes between it and the
+        iTIP message.
 
+        @return: C{tuple} of (processed, auto-processed, store inbox item, changes)
+        """
+
         # If there is no existing copy, then ignore
         if self.recipient_calendar is None:
             log.debug("ImplicitProcessing - originator '%s' to recipient '%s' ignoring METHOD:CANCEL, UID: '%s' - attendee has no copy" % (self.originator.cuaddr, self.recipient.cuaddr, self.uid))
-            result = (True, True, None)
+            result = (True, True, True, None)
         else:
             # Need to check for auto-respond attendees. These need to suppress the inbox message
             # if the cancel is processed. However, if the principal is a user we always force the

Modified: CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/test/test_icaldiff.py
===================================================================
--- CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/test/test_icaldiff.py	2014-01-28 00:07:43 UTC (rev 12458)
+++ CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/test/test_icaldiff.py	2014-01-28 01:25:30 UTC (rev 12459)
@@ -4292,6 +4292,576 @@
             self.assertEqual(got_rids, rids, msg="%s expected R-IDs: '%s', got: '%s'" % (description, rids, got_rids,))
 
 
+    def test_attendee_needs_action(self):
+
+        data = (
+            (
+                "#1.1 Simple component, no change",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                set(),
+                False,
+            ),
+            (
+                "#1.2 Simple component, one property change",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test1
+END:VEVENT
+END:VCALENDAR
+""",
+                set(),
+                False,
+            ),
+            (
+                "#1.3 Simple component, date property change",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T130000Z
+DTEND:20080601T140000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                set((None,)),
+                False,
+            ),
+            (
+                "#1.4 Simple component, duration property change",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DURATION:PT1H
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DURATION:PT2H
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                set((None,)),
+                False,
+            ),
+            (
+                "#2.1 Recurring component, no change",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""",
+                set(),
+                False,
+            ),
+            (
+                "#2.2 Recurring component, change property",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test1
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""",
+                set(),
+                False,
+            ),
+            (
+                "#2.3 Recurring component, change date",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T130000Z
+DTEND:20080601T140000Z
+SUMMARY:Test1
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""",
+                set((None,)),
+                True,
+            ),
+            (
+                "#2.4 Recurring component, change rule",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=WEEKLY
+END:VEVENT
+END:VCALENDAR
+""",
+                set(),
+                True,
+            ),
+            (
+                "#2.5 Recurring component, truncate rule",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY;COUNT=10
+END:VEVENT
+END:VCALENDAR
+""",
+                set(),
+                False,
+            ),
+            (
+                "#2.6 Recurring component, expand rule",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY;COUNT=5
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY;COUNT=10
+END:VEVENT
+END:VCALENDAR
+""",
+                set(),
+                False,
+            ),
+            (
+                "#3.1 Recurring component with override, no change",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+BEGIN:VEVENT
+UID:12345-67890
+RECURRENCE-ID:20080602T120000Z
+DTSTART:20080602T120000Z
+DTEND:20080602T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+BEGIN:VEVENT
+UID:12345-67890
+RECURRENCE-ID:20080602T120000Z
+DTSTART:20080602T120000Z
+DTEND:20080602T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                set(),
+                False,
+            ),
+            (
+                "#3.2 Recurring component with override, property change",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+BEGIN:VEVENT
+UID:12345-67890
+RECURRENCE-ID:20080602T120000Z
+DTSTART:20080602T120000Z
+DTEND:20080602T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test1
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+BEGIN:VEVENT
+UID:12345-67890
+RECURRENCE-ID:20080602T120000Z
+DTSTART:20080602T120000Z
+DTEND:20080602T130000Z
+SUMMARY:Test2
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                set(),
+                False,
+            ),
+            (
+                "#3.3 Recurring component with override, date change in override",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+BEGIN:VEVENT
+UID:12345-67890
+RECURRENCE-ID:20080602T120000Z
+DTSTART:20080602T120000Z
+DTEND:20080602T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test1
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+BEGIN:VEVENT
+UID:12345-67890
+RECURRENCE-ID:20080602T120000Z
+DTSTART:20080602T130000Z
+DTEND:20080602T140000Z
+SUMMARY:Test2
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                set(("20080602T120000Z",)),
+                False,
+            ),
+            (
+                "#3.4 Recurring component with override, date change in override and master",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T120000Z
+DTEND:20080601T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+BEGIN:VEVENT
+UID:12345-67890
+RECURRENCE-ID:20080602T120000Z
+DTSTART:20080602T120000Z
+DTEND:20080602T130000Z
+SUMMARY:Test
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20080601T130000Z
+DTEND:20080601T140000Z
+SUMMARY:Test1
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+BEGIN:VEVENT
+UID:12345-67890
+RECURRENCE-ID:20080602T130000Z
+DTSTART:20080602T130000Z
+DTEND:20080602T140000Z
+SUMMARY:Test2
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+END:VEVENT
+END:VCALENDAR
+""",
+                set((None,)),
+                True,
+            ),
+        )
+
+        for description, calendar1, calendar2, rids, rescheduled in data:
+            differ = iCalDiff(Component.fromString(calendar1), Component.fromString(calendar2), False)
+            diffs = differ.whatIsDifferent()
+            got_rids, got_rescheduled = differ.attendeeNeedsAction(diffs)
+            rids = set([DateTime.parseText(k) if k else None for k in rids])
+            self.assertEqual(got_rids, rids, msg="%s expected R-IDs: '%s', got: '%s'" % (description, rids, got_rids,))
+            self.assertEqual(got_rescheduled, rescheduled, msg="%s expected rescheduled: '%s', got: '%s'" % (description, rescheduled, got_rescheduled,))
+
+
     def test_organizer_smart_merge(self):
 
         data1 = (

Modified: CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/test/test_implicit.py
===================================================================
--- CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/test/test_implicit.py	2014-01-28 00:07:43 UTC (rev 12458)
+++ CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/test/test_implicit.py	2014-01-28 01:25:30 UTC (rev 12459)
@@ -1467,7 +1467,7 @@
 
         # Need refreshes to occur immediately, not via reactor.callLater
         self.patch(config.Scheduling.Options, "AttendeeRefreshBatch", 5)
-        self.patch(config.Scheduling.Options, "AttendeeRefreshBatchDelaySeconds", 1)
+        self.patch(config.Scheduling.Options.WorkQueues, "AttendeeRefreshBatchDelaySeconds", 1)
 
         yield self._createCalendarObject(data1, "user01", "test.ics")
 

Modified: CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/test/test_itip.py
===================================================================
--- CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/test/test_itip.py	2014-01-28 00:07:43 UTC (rev 12458)
+++ CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/test/test_itip.py	2014-01-28 01:25:30 UTC (rev 12459)
@@ -34,6 +34,224 @@
     iCalendar support tests
     """
 
+    def test_processRequest_mergeAttendeePartstat(self):
+        """
+        Test iTIPProcessing.processRequest properly preserves attendee PARTSTAT when there is no date change
+        """
+
+        data = (
+            (
+                "1.1 Simple Request - summary change only, partstats match",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20071114T000000Z
+DURATION:PT1H
+DTSTAMP:20071114T000000Z
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user02 at example.com
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Test
+TRANSP:TRANSPARENT
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20071114T000000Z
+DURATION:PT1H
+DTSTAMP:20071114T000000Z
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user02 at example.com
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Test1
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20071114T000000Z
+DURATION:PT1H
+DTSTAMP:20071114T000000Z
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user02 at example.com
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Test1
+TRANSP:TRANSPARENT
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "1.2 Simple Request - summary change only, partstat mismatch",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20071114T000000Z
+DURATION:PT1H
+DTSTAMP:20071114T000000Z
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user02 at example.com
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Test
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20071114T000000Z
+DURATION:PT1H
+DTSTAMP:20071114T000000Z
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user02 at example.com
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Test1
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20071114T000000Z
+DURATION:PT1H
+DTSTAMP:20071114T000000Z
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user02 at example.com
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Test1
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "1.3 Simple Request - date change, partstats match",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20071114T000000Z
+DURATION:PT1H
+DTSTAMP:20071114T000000Z
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user02 at example.com
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Test
+TRANSP:TRANSPARENT
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20071114T000000Z
+DURATION:PT1H
+DTSTAMP:20071114T000000Z
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user02 at example.com
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Test
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20071114T000000Z
+DURATION:PT1H
+DTSTAMP:20071114T000000Z
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user02 at example.com
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Test
+TRANSP:TRANSPARENT
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "1.4 Simple Request - date change, partstat mismatch",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20071114T000000Z
+DURATION:PT1H
+DTSTAMP:20071114T000000Z
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user02 at example.com
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Test
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20071115T000000Z
+DURATION:PT1H
+DTSTAMP:20071114T000000Z
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user02 at example.com
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Test
+END:VEVENT
+END:VCALENDAR
+""",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20071115T000000Z
+DURATION:PT1H
+DTSTAMP:20071114T000000Z
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user02 at example.com
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Test
+TRANSP:TRANSPARENT
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+        )
+
+        for title, calendar_txt, itip_txt, changed_txt in data:
+            calendar = Component.fromString(calendar_txt)
+            itip = Component.fromString(itip_txt)
+            changed = Component.fromString(changed_txt)
+
+            result, _ignore = iTipProcessing.processRequest(itip, calendar, "mailto:user02 at example.com")
+            self.assertEqual(result, changed, msg="Calendar mismatch: %s" % (title,))
+
+
     def test_processReply(self):
         """
         Test iTIPProcessing.processReply
@@ -1077,11 +1295,6 @@
             calendar = Component.fromString(calendar_txt)
             itipmsg = Component.fromString(itipmsg_txt)
             reply_success, reply_processed = iTipProcessing.processReply(itipmsg, calendar)
-#            if not description.startswith("#3.1"):
-#                continue
-#            print(description)
-#            print(str(calendar))
-#            print(str(result))
             self.assertEqual(
                 str(calendar).replace("\r", "").replace("\n ", ""),
                 str(result).replace("\n ", ""),

Modified: CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/work.py
===================================================================
--- CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/work.py	2014-01-28 00:07:43 UTC (rev 12458)
+++ CalendarServer/branches/users/cdaboo/scheduling-queue-refresh/txdav/caldav/datastore/scheduling/work.py	2014-01-28 01:25:30 UTC (rev 12459)
@@ -20,7 +20,7 @@
 from twext.enterprise.queue import WorkItem
 from twext.python.log import Logger
 
-from twisted.internet.defer import inlineCallbacks, returnValue
+from twisted.internet.defer import inlineCallbacks, returnValue, Deferred
 
 from twistedcaldav import caldavxml
 from twistedcaldav.config import config
@@ -102,6 +102,8 @@
     their calendar object resource.
     """
 
+    _allDoneCallback = None
+
     @classmethod
     @inlineCallbacks
     def schedule(cls, txn, uid, action, home, resource, calendar, organizer, smart_merge):
@@ -116,7 +118,7 @@
         Note that for (3), when work executes the resource will have been removed.
         """
         # Always queue up new work - coalescing happens when work is executed
-        notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=config.Scheduling.Options.QueuedRequestDelaySeconds)
+        notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=config.Scheduling.Options.WorkQueues.RequestDelaySeconds)
         proposal = (yield txn.enqueue(
             cls,
             notBefore=notBefore,
@@ -142,6 +144,13 @@
         returnValue(len(rows) > 0)
 
 
+    @classmethod
+    def allDone(cls):
+        d = Deferred()
+        cls._allDoneCallback = d.callback
+        return d
+
+
     @inlineCallbacks
     def doWork(self):
 
@@ -161,6 +170,9 @@
             scheduler = ImplicitScheduler()
             yield scheduler.queuedOrganizerProcessing(self.transaction, scheduleActionFromSQL[self.scheduleAction], home, resource, self.icalendarUid, calendar, self.smartMerge)
 
+            if self._allDoneCallback:
+                self._allDoneCallback(None)
+
         except Exception, e:
             log.debug("ScheduleOrganizerWork - exception ID: {id}, UID: '{uid}', {err}", id=self.workID, uid=self.icalendarUid, err=str(e))
             raise
@@ -210,7 +222,7 @@
     @inlineCallbacks
     def reply(cls, txn, home, resource, changedRids, attendee):
         # Always queue up new work - coalescing happens when work is executed
-        notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=config.Scheduling.Options.QueuedReplyDelaySeconds)
+        notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=config.Scheduling.Options.WorkQueues.ReplyDelaySeconds)
         proposal = (yield txn.enqueue(
             cls,
             notBefore=notBefore,
@@ -286,7 +298,7 @@
     @inlineCallbacks
     def replyCancel(cls, txn, home, calendar, attendee):
         # Always queue up new work - coalescing happens when work is executed
-        notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=config.Scheduling.Options.QueuedReplyDelaySeconds)
+        notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=config.Scheduling.Options.WorkQueues.ReplyDelaySeconds)
         proposal = (yield txn.enqueue(
             cls,
             notBefore=notBefore,
@@ -348,7 +360,11 @@
     not unique wrt to attendees - this means that two simultaneous refreshes can happily insert the
     same set of attendees without running into unique constraints and thus without having to use
     savepoints to cope with that. This will mean duplicate attendees listed in the table, but we take
-    care of that when executing the work item, as per the next point.
+    care of that when executing the work item, as per the next point. We also always schedule a new work
+    item for the refresh - even if others are present. The work items are coalesced when executed, with
+    the actual refresh only running at the time of the latest enqueued item. That ensures there is always
+    a pause between a change that causes a refresh and then next actual refresh batch being done, giving
+    some breathing space in case rapid changes are happening to the iCalendar data.
 
     2) When a work item is triggered we get the set of unique attendees needing a refresh from the
     SCHEDULE_REFRESH_ATTENDEES table. We split out a batch of those to actually refresh - with the
@@ -385,7 +401,7 @@
             ).on(txn)
 
         # Always queue up new work - coalescing happens when work is executed
-        notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=config.Scheduling.Options.AttendeeRefreshBatchDelaySeconds)
+        notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=config.Scheduling.Options.WorkQueues.AttendeeRefreshBatchDelaySeconds)
         proposal = (yield txn.enqueue(
             cls,
             icalendarUid=organizer_resource.uid(),
@@ -440,7 +456,7 @@
 
         # Reschedule work item if pending attendees remain.
         if len(pendingAttendees) != 0:
-            notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=config.Scheduling.Options.AttendeeRefreshBatchIntervalSeconds)
+            notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=config.Scheduling.Options.WorkQueues.AttendeeRefreshBatchIntervalSeconds)
             yield self.transaction.enqueue(
                 self.__class__,
                 homeResourceID=self.homeResourceID,
@@ -517,7 +533,7 @@
     @inlineCallbacks
     def autoReply(cls, txn, resource, partstat):
         # Always queue up new work - coalescing happens when work is executed
-        notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=config.Scheduling.Options.AutoReplyDelaySeconds)
+        notBefore = datetime.datetime.utcnow() + datetime.timedelta(seconds=config.Scheduling.Options.WorkQueues.AutoReplyDelaySeconds)
         proposal = (yield txn.enqueue(
             cls,
             icalendarUid=resource.uid(),
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20140312/39a9e490/attachment.html>


More information about the calendarserver-changes mailing list