class BulkMessageApplication(GoApplicationWorker): """ Application that accepts 'send message' commands and does exactly that. """ worker_name = 'bulk_message_application' max_ack_window = 100 max_ack_wait = 100 monitor_interval = 20 monitor_window_cleanup = True @inlineCallbacks def setup_application(self): yield super(BulkMessageApplication, self).setup_application() wm_redis = self.redis.sub_manager('%s:window_manager' % (self.worker_name, )) self.window_manager = WindowManager(wm_redis, window_size=self.max_ack_window, flight_lifetime=self.max_ack_wait) self.window_manager.monitor(self.on_window_key_ready, interval=self.monitor_interval, cleanup=self.monitor_window_cleanup, cleanup_callback=self.on_window_cleanup) @inlineCallbacks def teardown_application(self): yield super(BulkMessageApplication, self).teardown_application() self.window_manager.stop() @inlineCallbacks def on_window_key_ready(self, window_id, flight_key): data = yield self.window_manager.get_data(window_id, flight_key) to_addr = data['to_addr'] content = data['content'] msg_options = data['msg_options'] msg = yield self.send_to(to_addr, content, endpoint='default', **msg_options) yield self.window_manager.set_external_id(window_id, flight_key, msg['message_id']) def on_window_cleanup(self, window_id): log.info('Finished window %s, removing.' % (window_id, )) def get_window_id(self, conversation_key, batch_id): return ':'.join([conversation_key, batch_id]) @inlineCallbacks def send_message_via_window(self, conv, window_id, batch_id, to_addr, msg_options, content): yield self.window_manager.create_window(window_id, strict=False) yield self.window_manager.add( window_id, { 'batch_id': batch_id, 'to_addr': to_addr, 'content': content, 'msg_options': msg_options, }) @inlineCallbacks def process_command_bulk_send(self, user_account_key, conversation_key, batch_id, msg_options, content, dedupe, delivery_class, **extra_params): conv = yield self.get_conversation(user_account_key, conversation_key) if conv is None: log.warning("Cannot find conversation '%s' for user '%s'." % (conversation_key, user_account_key)) return to_addresses = [] for contacts_batch in ( yield conv.get_opted_in_contact_bunches(delivery_class)): for contact in (yield contacts_batch): to_addresses.append(contact.addr_for(delivery_class)) if dedupe: to_addresses = set(to_addresses) self.add_conv_to_msg_options(conv, msg_options) window_id = self.get_window_id(conversation_key, batch_id) for to_addr in to_addresses: yield self.send_message_via_window(conv, window_id, batch_id, to_addr, msg_options, content) def consume_ack(self, event): return self.handle_event(event) def consume_nack(self, event): return self.handle_event(event) @inlineCallbacks def handle_event(self, event): message = yield self.find_message_for_event(event) if message is None: log.error('Unable to find message for %s, user_message_id: %s' % (event['event_type'], event.get('user_message_id'))) return msg_mdh = self.get_metadata_helper(message) conv = yield msg_mdh.get_conversation() if conv: window_id = self.get_window_id(conv.key, conv.batch.key) flight_key = yield self.window_manager.get_internal_id( window_id, message['message_id']) yield self.window_manager.remove_key(window_id, flight_key) @inlineCallbacks def process_command_initial_action_hack(self, user_account_key, conversation_key, **kwargs): # HACK: This lets us do whatever we used to do when we got a `start' # message without having horrible app-specific view logic. # TODO: Remove this when we've decoupled the various conversation # actions from the lifecycle. conv = yield self.get_conversation(user_account_key, conversation_key) if conv is None: log.warning("Cannot find conversation '%s' for user '%s'." % (user_account_key, conversation_key)) return kwargs.setdefault('content', conv.description) kwargs.setdefault('dedupe', False) yield self.process_command_bulk_send(user_account_key=user_account_key, conversation_key=conversation_key, **kwargs)
class BulkMessageApplication(GoApplicationWorker): """ Application that accepts 'send message' commands and does exactly that. """ worker_name = 'bulk_message_application' max_ack_window = 100 max_ack_wait = 100 monitor_interval = 20 monitor_window_cleanup = True @inlineCallbacks def setup_application(self): yield super(BulkMessageApplication, self).setup_application() wm_redis = self.redis.sub_manager('%s:window_manager' % ( self.worker_name,)) self.window_manager = WindowManager(wm_redis, window_size=self.max_ack_window, flight_lifetime=self.max_ack_wait) self.window_manager.monitor(self.on_window_key_ready, interval=self.monitor_interval, cleanup=self.monitor_window_cleanup, cleanup_callback=self.on_window_cleanup) @inlineCallbacks def teardown_application(self): yield super(BulkMessageApplication, self).teardown_application() self.window_manager.stop() @inlineCallbacks def on_window_key_ready(self, window_id, flight_key): data = yield self.window_manager.get_data(window_id, flight_key) to_addr = data['to_addr'] content = data['content'] msg_options = data['msg_options'] msg = yield self.send_to( to_addr, content, endpoint='default', **msg_options) yield self.window_manager.set_external_id(window_id, flight_key, msg['message_id']) def on_window_cleanup(self, window_id): log.info('Finished window %s, removing.' % (window_id,)) def get_window_id(self, conversation_key, batch_id): return ':'.join([conversation_key, batch_id]) @inlineCallbacks def send_message_via_window(self, conv, window_id, batch_id, to_addr, msg_options, content): yield self.window_manager.create_window(window_id, strict=False) yield self.window_manager.add(window_id, { 'batch_id': batch_id, 'to_addr': to_addr, 'content': content, 'msg_options': msg_options, }) @inlineCallbacks def process_command_bulk_send(self, user_account_key, conversation_key, batch_id, msg_options, content, dedupe, delivery_class, **extra_params): conv = yield self.get_conversation(user_account_key, conversation_key) if conv is None: log.warning("Cannot find conversation '%s' for user '%s'." % ( conversation_key, user_account_key)) return to_addresses = [] for contacts_batch in ( yield conv.get_opted_in_contact_bunches(delivery_class)): for contact in (yield contacts_batch): to_addresses.append(contact.addr_for(delivery_class)) if dedupe: to_addresses = set(to_addresses) self.add_conv_to_msg_options(conv, msg_options) window_id = self.get_window_id(conversation_key, batch_id) for to_addr in to_addresses: yield self.send_message_via_window( conv, window_id, batch_id, to_addr, msg_options, content) def consume_ack(self, event): return self.handle_event(event) def consume_nack(self, event): return self.handle_event(event) @inlineCallbacks def handle_event(self, event): message = yield self.find_message_for_event(event) if message is None: log.error('Unable to find message for %s, user_message_id: %s' % ( event['event_type'], event.get('user_message_id'))) return msg_mdh = self.get_metadata_helper(message) conv = yield msg_mdh.get_conversation() if conv: window_id = self.get_window_id(conv.key, conv.batch.key) flight_key = yield self.window_manager.get_internal_id(window_id, message['message_id']) yield self.window_manager.remove_key(window_id, flight_key) @inlineCallbacks def process_command_initial_action_hack(self, user_account_key, conversation_key, **kwargs): # HACK: This lets us do whatever we used to do when we got a `start' # message without having horrible app-specific view logic. # TODO: Remove this when we've decoupled the various conversation # actions from the lifecycle. conv = yield self.get_conversation(user_account_key, conversation_key) if conv is None: log.warning("Cannot find conversation '%s' for user '%s'." % ( user_account_key, conversation_key)) return kwargs.setdefault('content', conv.description) kwargs.setdefault('dedupe', False) yield self.process_command_bulk_send( user_account_key=user_account_key, conversation_key=conversation_key, **kwargs)
class TestWindowManager(VumiTestCase): @inlineCallbacks def setUp(self): self.persistence_helper = self.add_helper(PersistenceHelper()) redis = yield self.persistence_helper.get_redis_manager() self.window_id = 'window_id' # Patch the clock so we can control time self.clock = Clock() self.patch(WindowManager, 'get_clock', lambda _: self.clock) self.wm = WindowManager(redis, window_size=10, flight_lifetime=10) self.add_cleanup(self.wm.stop) yield self.wm.create_window(self.window_id) self.redis = self.wm.redis @inlineCallbacks def test_windows(self): windows = yield self.wm.get_windows() self.assertTrue(self.window_id in windows) def test_strict_window_recreation(self): return self.assertFailure( self.wm.create_window(self.window_id, strict=True), WindowException) @inlineCallbacks def test_window_recreation(self): orig_clock_time = self.clock.seconds() clock_time = yield self.wm.create_window(self.window_id) self.assertEqual(clock_time, orig_clock_time) @inlineCallbacks def test_window_removal(self): yield self.wm.add(self.window_id, 1) yield self.assertFailure(self.wm.remove_window(self.window_id), WindowException) key = yield self.wm.get_next_key(self.window_id) item = yield self.wm.get_data(self.window_id, key) self.assertEqual(item, 1) self.assertEqual((yield self.wm.remove_window(self.window_id)), None) @inlineCallbacks def test_adding_to_window(self): for i in range(10): yield self.wm.add(self.window_id, i) window_key = self.wm.window_key(self.window_id) window_members = yield self.redis.llen(window_key) self.assertEqual(window_members, 10) @inlineCallbacks def test_fetching_from_window(self): for i in range(12): yield self.wm.add(self.window_id, i) flight_keys = [] for i in range(10): flight_key = yield self.wm.get_next_key(self.window_id) self.assertTrue(flight_key) flight_keys.append(flight_key) out_of_window_flight = yield self.wm.get_next_key(self.window_id) self.assertEqual(out_of_window_flight, None) # We should get data out in the order we put it in for i, flight_key in enumerate(flight_keys): data = yield self.wm.get_data(self.window_id, flight_key) self.assertEqual(data, i) # Removing one should allow for space for the next to fill up yield self.wm.remove_key(self.window_id, flight_keys[0]) next_flight_key = yield self.wm.get_next_key(self.window_id) self.assertTrue(next_flight_key) @inlineCallbacks def test_set_and_external_id(self): yield self.wm.set_external_id(self.window_id, "flight_key", "external_id") self.assertEqual( (yield self.wm.get_external_id(self.window_id, "flight_key")), "external_id") self.assertEqual( (yield self.wm.get_internal_id(self.window_id, "external_id")), "flight_key") @inlineCallbacks def test_remove_key_removes_external_and_internal_id(self): yield self.wm.set_external_id(self.window_id, "flight_key", "external_id") yield self.wm.remove_key(self.window_id, "flight_key") self.assertEqual( (yield self.wm.get_external_id(self.window_id, "flight_key")), None) self.assertEqual( (yield self.wm.get_internal_id(self.window_id, "external_id")), None) @inlineCallbacks def assert_count_waiting(self, window_id, amount): self.assertEqual((yield self.wm.count_waiting(window_id)), amount) @inlineCallbacks def assert_expired_keys(self, window_id, amount): # Stuff has taken too long and so we should get 10 expired keys expired_keys = yield self.wm.get_expired_flight_keys(window_id) self.assertEqual(len(expired_keys), amount) @inlineCallbacks def assert_in_flight(self, window_id, amount): self.assertEqual((yield self.wm.count_in_flight(window_id)), amount) @inlineCallbacks def slide_window(self, limit=10): for i in range(limit): yield self.wm.get_next_key(self.window_id) @inlineCallbacks def test_expiry_of_acks(self): def mock_clock_time(self): return self._clocktime self.patch(WindowManager, 'get_clocktime', mock_clock_time) self.wm._clocktime = 0 for i in range(30): yield self.wm.add(self.window_id, i) # We're manually setting the clock instead of using clock.advance() # so we can wait for the deferreds to finish before continuing to the # next clear_expired_flight_keys run since LoopingCall() will only fire # again if the previous run has completed. yield self.slide_window() self.wm._clocktime = 10 yield self.wm.clear_expired_flight_keys() self.assert_expired_keys(self.window_id, 10) yield self.slide_window() self.wm._clocktime = 20 yield self.wm.clear_expired_flight_keys() self.assert_expired_keys(self.window_id, 20) yield self.slide_window() self.wm._clocktime = 30 yield self.wm.clear_expired_flight_keys() self.assert_expired_keys(self.window_id, 30) self.assert_in_flight(self.window_id, 0) self.assert_count_waiting(self.window_id, 0) @inlineCallbacks def test_monitor_windows(self): yield self.wm.remove_window(self.window_id) window_ids = ['window_id_1', 'window_id_2'] for window_id in window_ids: yield self.wm.create_window(window_id) for i in range(20): yield self.wm.add(window_id, i) key_callbacks = {} def callback(window_id, key): key_callbacks.setdefault(window_id, []).append(key) cleanup_callbacks = [] def cleanup_callback(window_id): cleanup_callbacks.append(window_id) yield self.wm._monitor_windows(callback, False) self.assertEqual(set(key_callbacks.keys()), set(window_ids)) self.assertEqual(len(key_callbacks.values()[0]), 10) self.assertEqual(len(key_callbacks.values()[1]), 10) yield self.wm._monitor_windows(callback, False) # Nothing should've changed since we haven't removed anything. self.assertEqual(len(key_callbacks.values()[0]), 10) self.assertEqual(len(key_callbacks.values()[1]), 10) for window_id, keys in key_callbacks.items(): for key in keys: yield self.wm.remove_key(window_id, key) yield self.wm._monitor_windows(callback, False) # Everything should've been processed now self.assertEqual(len(key_callbacks.values()[0]), 20) self.assertEqual(len(key_callbacks.values()[1]), 20) # Now run again but cleanup the empty windows self.assertEqual(set((yield self.wm.get_windows())), set(window_ids)) for window_id, keys in key_callbacks.items(): for key in keys: yield self.wm.remove_key(window_id, key) yield self.wm._monitor_windows(callback, True, cleanup_callback) self.assertEqual(len(key_callbacks.values()[0]), 20) self.assertEqual(len(key_callbacks.values()[1]), 20) self.assertEqual((yield self.wm.get_windows()), []) self.assertEqual(set(cleanup_callbacks), set(window_ids))