# Copyright (c) 2010-2012 OpenStack Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import shutil
import unittest
from contextlib import contextmanager

import eventlet
import os
import logging
import errno
import math
import time
from shutil import rmtree, copy
from tempfile import mkdtemp, NamedTemporaryFile
import json

from unittest import mock
from unittest.mock import patch, call

from swift.common.db_replicator import BrokerAnnotatedLogger
from swift.container.backend import DATADIR
from swift.common import db_replicator
from swift.common.utils import (hash_path, storage_directory, Timestamp, quote,
                                mkdirs, listdir)
from swift.common.exceptions import DriveNotMounted
from swift.common.swob import HTTPException

from test import unit
from test.debug_logger import debug_logger
from test.unit import attach_fake_replication_rpc, BaseUnitTestCase
from test.unit.common.test_db import ExampleBroker


TEST_ACCOUNT_NAME = 'a c t'
TEST_CONTAINER_NAME = 'c o n'


@contextmanager
def lock_parent_directory(filename):
    yield True


class FakeRing(object):
    class Ring(object):
        devs = []
        replica_count = 3

        def __init__(self, path, reload_time=15, ring_name=None):
            pass

        def get_part(self, account, container=None, obj=None):
            return 0

        def get_part_nodes(self, part):
            return []

        def get_more_nodes(self, *args):
            return []


class FakeRingWithSingleNode(object):
    class Ring(object):
        devs = [dict(
            id=1, weight=10.0, zone=1, ip='1.1.1.1', port=6200, device='sdb',
            meta='', replication_ip='1.1.1.1', replication_port=6200, region=1
        )]
        replica_count = 3

        def __init__(self, path, reload_time=15, ring_name=None):
            pass

        def get_part(self, account, container=None, obj=None):
            return 0

        def get_part_nodes(self, part):
            return self.devs

        def get_more_nodes(self, *args):
            return (d for d in self.devs)


class FakeRingWithNodes(object):
    class Ring(object):
        devs = [dict(
            id=1, weight=10.0, zone=1, ip='1.1.1.1', port=6200, device='sdb',
            meta='', replication_ip='1.1.1.1', replication_port=6200, region=1
        ), dict(
            id=2, weight=10.0, zone=2, ip='1.1.1.2', port=6200, device='sdb',
            meta='', replication_ip='1.1.1.2', replication_port=6200, region=2
        ), dict(
            id=3, weight=10.0, zone=3, ip='1.1.1.3', port=6200, device='sdb',
            meta='', replication_ip='1.1.1.3', replication_port=6200, region=1
        ), dict(
            id=4, weight=10.0, zone=4, ip='1.1.1.4', port=6200, device='sdb',
            meta='', replication_ip='1.1.1.4', replication_port=6200, region=2
        ), dict(
            id=5, weight=10.0, zone=5, ip='1.1.1.5', port=6200, device='sdb',
            meta='', replication_ip='1.1.1.5', replication_port=6200, region=1
        ), dict(
            id=6, weight=10.0, zone=6, ip='1.1.1.6', port=6200, device='sdb',
            meta='', replication_ip='1.1.1.6', replication_port=6200, region=2
        )]
        replica_count = 3

        def __init__(self, path, reload_time=15, ring_name=None):
            pass

        def get_part(self, account, container=None, obj=None):
            return 0

        def get_part_nodes(self, part):
            return self.devs[:3]

        def get_more_nodes(self, *args):
            return (d for d in self.devs[3:])


class FakeProcess(object):
    def __init__(self, *codes):
        self.codes = iter(codes)
        self.args = None
        self.kwargs = None

    def __call__(self, *args, **kwargs):
        self.args = args
        self.kwargs = kwargs

        class Failure(object):
            def communicate(innerself):
                next_item = next(self.codes)
                if isinstance(next_item, int):
                    innerself.returncode = next_item
                    return next_item
                raise next_item
        return Failure()


@contextmanager
def _mock_process(*args):
    orig_process = db_replicator.subprocess.Popen
    db_replicator.subprocess.Popen = FakeProcess(*args)
    yield db_replicator.subprocess.Popen
    db_replicator.subprocess.Popen = orig_process


class ReplHttp(object):
    def __init__(self, response=None, set_status=200):
        if isinstance(response, str):
            response = response.encode('ascii')
        self.response = response
        self.set_status = set_status
    replicated = False
    host = 'localhost'
    node = {
        'ip': '127.0.0.1',
        'port': '6000',
        'device': 'sdb',
    }

    def replicate(self, *args):
        self.replicated = True

        class Response(object):
            status = self.set_status
            data = self.response

            def read(innerself):
                return self.response
        return Response()


class ChangingMtimesOs(object):
    def __init__(self):
        self.mtime = 0

    def __call__(self, *args, **kwargs):
        self.mtime += 1
        return self.mtime


class FakeBroker(object):
    get_repl_missing_table = False
    stub_replication_info = None
    db_type = 'container'
    db_contains_type = 'object'

    def __init__(self, db_file=None, account=TEST_ACCOUNT_NAME,
                 container=TEST_CONTAINER_NAME, **kwargs):
        self.db_file = db_file or __file__
        self.db_dir = os.path.dirname(self.db_file)
        self.locked = False
        self.metadata = {}

        self.info = {}
        if account is not None:
            self.info['account'] = account
        if container is not None:
            self.info['container'] = container

        if 'account' in self.info:
            self.path = self.info['account']
            if 'container' in self.info:
                self.path += '/' + self.info['container']

    @contextmanager
    def lock(self):
        self.locked = True
        yield True
        self.locked = False

    def get_sync(self, *args, **kwargs):
        return 5

    def get_syncs(self):
        return []

    def get_items_since(self, point, *args):
        if point == 0:
            return [{'ROWID': 1}]
        if point == -1:
            return [{'ROWID': 1}, {'ROWID': 2}]
        return []

    def merge_syncs(self, *args, **kwargs):
        self.args = args

    def merge_items(self, *args):
        self.args = args

    def get_replication_info(self):
        if self.get_repl_missing_table:
            raise Exception('no such table')
        info = dict(self.info)
        info.update({
            'hash': 12345,
            'delete_timestamp': 0,
            'put_timestamp': 1,
            'created_at': 1,
            'count': 0,
            'max_row': 99,
            'id': 'ID',
            'metadata': {}
        })
        if self.stub_replication_info:
            info.update(self.stub_replication_info)
        return info

    def get_max_row(self, table=None):
        return self.get_replication_info()['max_row']

    def is_reclaimable(self, now, reclaim_age):
        info = self.get_replication_info()
        return info['count'] == 0 and (
            (now - reclaim_age) >
            info['delete_timestamp'] >
            info['put_timestamp'])

    def get_other_replication_items(self):
        return None

    def reclaim(self, item_timestamp, sync_timestamp):
        pass

    def newid(self, remote_d):
        pass

    def update_metadata(self, metadata):
        self.metadata = metadata

    def merge_timestamps(self, created_at, put_timestamp, delete_timestamp):
        self.created_at = created_at
        self.put_timestamp = put_timestamp
        self.delete_timestamp = delete_timestamp

    def get_brokers(self):
        return [self]


class FakeAccountBroker(FakeBroker):
    db_type = 'account'
    db_contains_type = 'container'

    def __init__(self, db_file=None, account=TEST_ACCOUNT_NAME,
                 container=None, **kwargs):
        if container is not None:
            raise TypeError("FakeAccountBroker: no container allowed")
        super(FakeAccountBroker, self).__init__(
            db_file=db_file, account=account, container=None,
            **kwargs
        )


class ConcreteReplicator(db_replicator.Replicator):
    server_type = 'container'
    ring_file = 'container.ring.gz'
    brokerclass = FakeBroker
    datadir = DATADIR
    default_port = 1000


class TestBrokerAnnotatedLogger(unittest.TestCase):
    def setUp(self):
        self.logger = debug_logger('broker-logger-test')
        self.db_logger = BrokerAnnotatedLogger(logger=self.logger)
        self.tempdir = mkdtemp()

    def tearDown(self):
        shutil.rmtree(self.tempdir, ignore_errors=True)

    def test_log_broker(self):
        broker = FakeBroker(account='a', container='c@d',
                            db_file='/tmp/abc.db')

        def do_test(level_name, call):
            # exercise
            call(broker, 'bonjour %s %s', 'mes', 'amis')
            call(broker, 'hello my %s', 'friend%04ds')
            call(broker, 'greetings friend%04ds')

            # verify
            expected = [
                'bonjour mes amis, path: a/c%40d, db: /tmp/abc.db',
                'hello my friend%04ds, path: a/c%40d, db: /tmp/abc.db',
                'greetings friend%04ds, path: a/c%40d, db: /tmp/abc.db',
            ]
            self.assertEqual(expected,
                             self.logger.get_lines_for_level(level_name))

            # other levels should be empty
            for lvl, lines in self.logger.all_log_lines().items():
                if lvl != level_name:
                    self.assertFalse(lines)

            # clear between sub-tests
            self.logger.clear()

        do_test('debug', self.db_logger.debug)
        do_test('info', self.db_logger.info)
        do_test('warning', self.db_logger.warning)
        do_test('error', self.db_logger.error)

    def test_log_broker_exception(self):
        broker = FakeBroker(account='a', container='c', db_file='/tmp/abc.db')

        try:
            raise ValueError('test')
        except ValueError as err:
            self.db_logger.exception(broker, 'exception: %s', err)

        self.assertEqual(
            ['exception: test, path: a/c, db: /tmp/abc.db: '],
            self.logger.get_lines_for_level('error')
        )
        for lvl, lines in self.logger.all_log_lines().items():
            if lvl != 'error':
                self.assertFalse(lines)

    def test_log_broker_levels(self):
        # When level is disabled, no message should be formatted nor emitted.
        broker = FakeBroker(account='a', container='c', db_file='/tmp/abc.db')

        with mock.patch.object(self.logger, 'isEnabledFor',
                               return_value=False):
            self.db_logger.debug(broker, 'test')
            self.db_logger.info(broker, 'test')
            self.db_logger.warning(broker, 'test')
            self.db_logger.error(broker, 'test')
            self.db_logger.exception(broker, 'test %s', 'x')

        self.assertFalse(self.logger.all_log_lines())

    def test_log_broker_exception_while_logging(self):
        """
        If accessing broker.path or broker.db_file raises, the formatter should
        fall back to empty strings for that field.
        """
        class WeirdBroker(object):
            def __init__(self, db_file, path, raise_db=False,
                         raise_path=False):
                self._db_file = db_file
                self._path = path
                self._raise_db = raise_db
                self._raise_path = raise_path

            @property
            def db_file(self):
                if self._raise_db:
                    raise Exception('boom db')
                return self._db_file

            @property
            def path(self):
                if self._raise_path:
                    raise Exception('boom path')
                return self._path

        # --- Case 1: path access fails ---
        broker_path_boom = WeirdBroker('/tmp/abc.db', 'a/c', raise_path=True)
        self.db_logger.info(broker_path_boom, 'bonjour %s %s', 'mes', 'amis')
        info_lines = self.logger.get_lines_for_level('info')
        self.assertEqual(['bonjour mes amis, path: , db: /tmp/abc.db'],
                         info_lines)
        self.logger.clear()

        # --- Case 2: db_file access fails ---
        broker_db_boom = WeirdBroker('/tmp/abc.db', 'a/c', raise_db=True)
        self.db_logger.info(broker_db_boom, 'bonjour %s %s', 'mes', 'amis')
        info_lines = self.logger.get_lines_for_level('info')
        self.assertEqual(['bonjour mes amis, path: a/c, db: '], info_lines)
        self.logger.clear()

        # --- Case 3: both path and db_file fail ---
        broker_boom = WeirdBroker('/tmp/abc.db', 'a/c', raise_db=True,
                                  raise_path=True)
        self.db_logger.info(broker_boom, 'bonjour %s %s', 'mes', 'amis')
        info_lines = self.logger.get_lines_for_level('info')
        self.assertEqual(['bonjour mes amis, path: , db: '], info_lines)
        self.logger.clear()

        for lvl, lines in self.logger.all_log_lines().items():
            if lvl != 'info':
                self.assertFalse(lines)


class TestDBReplicator(unittest.TestCase):
    def setUp(self):
        db_replicator.ring = FakeRing()
        self.delete_db_calls = []
        self._patchers = []
        self.logger = debug_logger('test-replicator')
        self.temp_dir = mkdtemp()
        # recon cache path
        self.recon_cache = os.path.join(self.temp_dir, 'cache')
        os.mkdir(self.recon_cache)

    def tearDown(self):
        for patcher in self._patchers:
            patcher.stop()
        rmtree(self.temp_dir, ignore_errors=True)

    def _patch(self, patching_fn, *args, **kwargs):
        patcher = patching_fn(*args, **kwargs)
        patched_thing = patcher.start()
        self._patchers.append(patcher)
        return patched_thing

    def stub_delete_db(self, broker):
        self.delete_db_calls.append('/path/to/file')
        return True

    def test_creation(self):
        # later config should be extended to assert more config options
        replicator = ConcreteReplicator({'node_timeout': '3.5'})
        self.assertEqual(replicator.node_timeout, 3.5)
        self.assertEqual(replicator.databases_per_second, 50.0)

        replicator = ConcreteReplicator({'databases_per_second': '0.1'})
        self.assertEqual(replicator.node_timeout, 10)
        self.assertEqual(replicator.databases_per_second, 0.1)

    def test_repl_connection(self):
        node = {'replication_ip': '127.0.0.1', 'replication_port': 80,
                'device': 'sdb1'}
        conn = db_replicator.ReplConnection(node, '1234567890', 'abcdefg',
                                            logging.getLogger())

        def req(method, path, body, headers):
            self.assertEqual(method, 'REPLICATE')
            self.assertEqual(headers['Content-Type'], 'application/json')

        class Resp(object):
            def read(self):
                return 'data'
        resp = Resp()
        conn.request = req
        conn.getresponse = lambda *args: resp
        self.assertEqual(conn.replicate(1, 2, 3), resp)

        def other_req(method, path, body, headers):
            raise Exception('blah')
        conn.request = other_req

        class Closeable(object):
            closed = False

            def close(self):
                self.closed = True

        conn.sock = fake_sock = Closeable()
        self.assertIsNone(conn.replicate(1, 2, 3))
        self.assertTrue(fake_sock.closed)
        self.assertEqual(None, conn.sock)

    def test_rsync_file(self):
        replicator = ConcreteReplicator({})
        broker = FakeBroker(db_file='/some/file')

        with _mock_process(-1):
            self.assertEqual(
                False,
                replicator._rsync_file(broker, 'remote:/some/file'))
        with _mock_process(0):
            self.assertEqual(
                True,
                replicator._rsync_file(broker, 'remote:/some/file'))

    def test_rsync_file_popen_args(self):
        replicator = ConcreteReplicator({})
        broker = FakeBroker(db_file='/some/file')

        with _mock_process(0) as process:
            replicator._rsync_file(broker, 'remote:/some_file')
            exp_args = ([
                'rsync', '--quiet', '--no-motd',
                '--timeout=%s' % int(math.ceil(replicator.node_timeout)),
                '--contimeout=%s' % int(math.ceil(replicator.conn_timeout)),
                '--whole-file', '/some/file', 'remote:/some_file'],)
            self.assertEqual(exp_args, process.args)

    def test_rsync_file_popen_args_whole_file_false(self):
        replicator = ConcreteReplicator({})
        broker = FakeBroker(db_file='/some/file')
        with _mock_process(0) as process:
            replicator._rsync_file(broker, 'remote:/some_file', False)
            exp_args = ([
                'rsync', '--quiet', '--no-motd',
                '--timeout=%s' % int(math.ceil(replicator.node_timeout)),
                '--contimeout=%s' % int(math.ceil(replicator.conn_timeout)),
                '/some/file', 'remote:/some_file'],)
            self.assertEqual(exp_args, process.args)

    def test_rsync_file_popen_args_different_region_and_rsync_compress(self):
        replicator = ConcreteReplicator({})
        broker = FakeBroker(db_file='/some/file')

        for rsync_compress in (False, True):
            replicator.rsync_compress = rsync_compress
            for different_region in (False, True):
                with _mock_process(0) as process:
                    replicator._rsync_file(broker, 'remote:/some_file',
                                           False, different_region)
                    if rsync_compress and different_region:
                        # --compress arg should be passed to rsync binary
                        # only when rsync_compress option is enabled
                        # AND destination node is in a different
                        # region
                        self.assertTrue('--compress' in process.args[0])
                    else:
                        self.assertFalse('--compress' in process.args[0])

    def test_rsync_db(self):
        replicator = ConcreteReplicator({})
        fake_device = {'replication_ip': '127.0.0.1', 'device': 'sda1'}

        calls = []

        def _rsync_file_stub(broker_arg, remote_spec, *args, **kwargs):
            calls.append({
                'broker': broker_arg,
                'remote_spec': remote_spec,
                'args': args,
                'kwargs': kwargs,
            })
            return True

        replicator._rsync_file = _rsync_file_stub
        replicator._rsync_db(FakeBroker(), fake_device, ReplHttp(), 'abcd')

        self.assertEqual(1, len(calls), "single rsync call")
        broker_arg = calls[0]['broker']
        self.assertTrue(os.path.isabs(broker_arg.db_file))
        self.assertFalse(os.path.isabs(broker_arg.path))

    def test_rsync_db_rsync_file_call(self):
        fake_device = {'ip': '127.0.0.1', 'port': '0',
                       'replication_ip': '127.0.0.1', 'replication_port': '0',
                       'device': 'sda1'}

        class MyTestReplicator(ConcreteReplicator):
            def __init__(self, db_file, remote_file):
                super(MyTestReplicator, self).__init__({})
                self.db_file = db_file
                self.remote_file = remote_file
                self._rsync_file_called = False

            def _rsync_file(self_, broker, remote_file, whole_file=True,
                            different_region=False):
                self.assertEqual(broker.db_file, self_.db_file)
                self.assertEqual(remote_file, self_.remote_file)
                self_._rsync_file_called = True
                return False

        broker = FakeBroker()
        remote_file = '127.0.0.1::container/sda1/tmp/abcd'
        replicator = MyTestReplicator(broker.db_file, remote_file)
        replicator._rsync_db(broker, fake_device, ReplHttp(), 'abcd')
        self.assertTrue(replicator._rsync_file_called)

    def test_rsync_db_rsync_file_failure(self):
        class MyTestReplicator(ConcreteReplicator):
            def __init__(self):
                super(MyTestReplicator, self).__init__({})
                self._rsync_file_called = False

            def _rsync_file(self_, *args, **kwargs):
                self.assertEqual(
                    False, self_._rsync_file_called,
                    '_sync_file() should only be called once')
                self_._rsync_file_called = True
                return False

        with patch('os.path.exists', lambda *args: True):
            replicator = MyTestReplicator()
            fake_device = {'ip': '127.0.0.1', 'replication_ip': '127.0.0.1',
                           'device': 'sda1'}
            replicator._rsync_db(FakeBroker(), fake_device, ReplHttp(), 'abcd')
            self.assertEqual(True, replicator._rsync_file_called)

    def test_rsync_db_change_after_sync(self):
        class MyTestReplicator(ConcreteReplicator):
            def __init__(self, broker):
                super(MyTestReplicator, self).__init__({})
                self.broker = broker
                self._rsync_file_call_count = 0

            def _rsync_file(self_, broker_arg, remote_file, whole_file=True,
                            different_region=False):
                self_._rsync_file_call_count += 1
                if self_._rsync_file_call_count == 1:
                    self.assertEqual(True, whole_file)
                    self.assertEqual(False, self_.broker.locked)
                elif self_._rsync_file_call_count == 2:
                    self.assertEqual(False, whole_file)
                    self.assertEqual(True, self_.broker.locked)
                else:
                    raise RuntimeError('_rsync_file() called too many times')
                return True

        # with journal file
        with patch('os.path.exists', lambda *args: True):
            broker = FakeBroker()
            replicator = MyTestReplicator(broker)
            fake_device = {'ip': '127.0.0.1', 'replication_ip': '127.0.0.1',
                           'device': 'sda1'}
            replicator._rsync_db(broker, fake_device, ReplHttp(), 'abcd')
            self.assertEqual(2, replicator._rsync_file_call_count)

        # with new mtime
        with patch('os.path.exists', lambda *args: False):
            with patch('os.path.getmtime', ChangingMtimesOs()):
                broker = FakeBroker()
                replicator = MyTestReplicator(broker)
                fake_device = {'ip': '127.0.0.1',
                               'replication_ip': '127.0.0.1',
                               'device': 'sda1'}
                replicator._rsync_db(broker, fake_device, ReplHttp(), 'abcd')
                self.assertEqual(2, replicator._rsync_file_call_count)

    def test_in_sync(self):
        replicator = ConcreteReplicator({})
        self.assertEqual(replicator._in_sync(
            {'id': 'a', 'point': 0, 'max_row': 0, 'hash': 'b'},
            {'id': 'a', 'point': -1, 'max_row': 0, 'hash': 'b'},
            FakeBroker(), -1), True)
        self.assertEqual(replicator._in_sync(
            {'id': 'a', 'point': -1, 'max_row': 0, 'hash': 'b'},
            {'id': 'a', 'point': -1, 'max_row': 10, 'hash': 'b'},
            FakeBroker(), -1), True)
        self.assertEqual(bool(replicator._in_sync(
            {'id': 'a', 'point': -1, 'max_row': 0, 'hash': 'c'},
            {'id': 'a', 'point': -1, 'max_row': 10, 'hash': 'd'},
            FakeBroker(), -1)), False)

    def test_run_once_no_local_device_in_ring(self):
        replicator = ConcreteReplicator({'recon_cache_path': self.recon_cache},
                                        logger=self.logger)
        with patch('swift.common.db_replicator.whataremyips',
                   return_value=['127.0.0.1']):
            replicator.run_once()
        expected = [
            "Can't find itself 127.0.0.1 with port 1000 "
            "in ring file, not replicating",
        ]
        self.assertEqual(expected, self.logger.get_lines_for_level('error'))

    def test_run_once_with_local_device_in_ring(self):
        base = 'swift.common.db_replicator.'
        with patch(base + 'whataremyips', return_value=['1.1.1.1']), \
                patch(base + 'ring', FakeRingWithNodes()):
            replicator = ConcreteReplicator({
                'bind_port': 6200,
                'recon_cache_path': self.recon_cache
            }, logger=self.logger)
            replicator.run_once()
        self.assertFalse(self.logger.get_lines_for_level('error'))

    def test_run_once_no_ips(self):
        replicator = ConcreteReplicator({}, logger=self.logger)
        self._patch(patch.object, db_replicator, 'whataremyips',
                    lambda *a, **kw: [])

        replicator.run_once()

        self.assertEqual(
            replicator.logger.get_lines_for_level('error'),
            ['ERROR Failed to get my own IPs?'])

    def test_run_once_node_is_not_mounted(self):
        db_replicator.ring = FakeRingWithSingleNode()
        # If a bind_ip is specified, it's plumbed into whataremyips() and
        # returned by itself.
        conf = {'mount_check': 'true', 'bind_ip': '1.1.1.1',
                'bind_port': 6200}
        replicator = ConcreteReplicator(conf, logger=self.logger)
        self.assertEqual(replicator.mount_check, True)
        self.assertEqual(replicator.port, 6200)

        err = ValueError('Boom!')

        def mock_check_drive(root, device, mount_check):
            self.assertEqual(root, replicator.root)
            self.assertEqual(device, replicator.ring.devs[0]['device'])
            self.assertEqual(mount_check, True)
            raise err

        self._patch(patch.object, db_replicator, 'check_drive',
                    mock_check_drive)
        replicator.run_once()

        self.assertEqual(
            replicator.logger.get_lines_for_level('warning'),
            ['Skipping: %s' % (err,)])

    def test_run_once_node_is_mounted(self):
        db_replicator.ring = FakeRingWithSingleNode()
        conf = {'mount_check': 'true', 'bind_port': 6200}
        replicator = ConcreteReplicator(conf, logger=self.logger)
        self.assertEqual(replicator.mount_check, True)
        self.assertEqual(replicator.port, 6200)

        def mock_unlink_older_than(path, mtime):
            self.assertEqual(path,
                             os.path.join(replicator.root,
                                          replicator.ring.devs[0]['device'],
                                          'tmp'))
            self.assertTrue(time.time() - replicator.reclaim_age >= mtime)

        def mock_spawn_n(fn, part, object_file, node_id):
            self.assertEqual('123', part)
            self.assertEqual('/srv/node/sda/c.db', object_file)
            self.assertEqual(1, node_id)

        self._patch(patch.object, db_replicator, 'whataremyips',
                    lambda *a, **kw: ['1.1.1.1'])
        self._patch(patch.object, db_replicator, 'unlink_older_than',
                    mock_unlink_older_than)
        self._patch(patch.object, db_replicator, 'roundrobin_datadirs',
                    lambda *args: [('123', '/srv/node/sda/c.db', 1)])
        self._patch(patch.object, replicator.cpool, 'spawn_n', mock_spawn_n)

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(ismount=True) as mocks:
            mock_os.path.isdir.return_value = True
            replicator.run_once()
            mock_os.path.isdir.assert_called_with(
                os.path.join(replicator.root,
                             replicator.ring.devs[0]['device'],
                             replicator.datadir))
            self.assertEqual([
                mock.call(os.path.join(
                    replicator.root,
                    replicator.ring.devs[0]['device'])),
            ], mocks['ismount'].call_args_list)

    def test_usync(self):
        fake_http = ReplHttp()
        replicator = ConcreteReplicator({})
        replicator._usync_db(0, FakeBroker(), fake_http, '12345', '67890')

    def test_usync_http_error_above_300(self):
        fake_http = ReplHttp(set_status=301)
        replicator = ConcreteReplicator({})
        self.assertFalse(
            replicator._usync_db(0, FakeBroker(), fake_http, '12345', '67890'))

    def test_usync_http_error_below_200(self):
        fake_http = ReplHttp(set_status=101)
        replicator = ConcreteReplicator({})
        self.assertFalse(
            replicator._usync_db(0, FakeBroker(), fake_http, '12345', '67890'))

    @mock.patch('swift.common.db_replicator.dump_recon_cache')
    @mock.patch('swift.common.db_replicator.time.time', return_value=1234.5678)
    def test_stats(self, mock_time, mock_recon_cache):
        replicator = ConcreteReplicator({}, logger=self.logger)
        replicator._zero_stats()
        self.assertEqual(replicator.stats['start'], mock_time.return_value)
        replicator._report_stats()
        self.assertEqual(self.logger.get_lines_for_level('info'), [
            'Attempted to replicate 0 dbs in 0.00000 seconds (0.00000/s)',
            'Removed 0 dbs',
            '0 successes, 0 failures',
            'diff:0 diff_capped:0 empty:0 hashmatch:0 no_change:0 '
            'remote_merge:0 rsync:0 ts_repl:0',
        ])
        self.assertEqual(1, len(mock_recon_cache.mock_calls))
        self.assertEqual(mock_recon_cache.mock_calls[0][1][0], {
            'replication_time': 0.0,
            'replication_last': mock_time.return_value,
            'replication_stats': replicator.stats,
        })

        mock_recon_cache.reset_mock()
        self.logger.clear()
        replicator.stats.update({
            'attempted': 30,
            'success': 25,
            'remove': 9,
            'failure': 1,

            'diff': 5,
            'diff_capped': 4,
            'empty': 7,
            'hashmatch': 8,
            'no_change': 6,
            'remote_merge': 2,
            'rsync': 3,
            'ts_repl': 10,
        })
        mock_time.return_value += 246.813576
        replicator._report_stats()
        self.maxDiff = None
        self.assertEqual(self.logger.get_lines_for_level('info'), [
            'Attempted to replicate 30 dbs in 246.81358 seconds (0.12155/s)',
            'Removed 9 dbs',
            '25 successes, 1 failures',
            'diff:5 diff_capped:4 empty:7 hashmatch:8 no_change:6 '
            'remote_merge:2 rsync:3 ts_repl:10',
        ])
        self.assertEqual(1, len(mock_recon_cache.mock_calls))
        self.assertEqual(mock_recon_cache.mock_calls[0][1][0], {
            'replication_time': 246.813576,
            'replication_last': mock_time.return_value,
            'replication_stats': replicator.stats,
        })

    def test_replicate_object(self):
        # verify return values from replicate_object
        db_replicator.ring = FakeRingWithNodes()
        db_path = '/path/to/file'
        replicator = ConcreteReplicator({}, logger=self.logger)
        info = FakeBroker().get_replication_info()
        # make remote appear to be in sync
        rinfo = {'point': info['max_row'], 'id': 'remote_id'}

        class FakeResponse(object):
            def __init__(self, status, rinfo):
                self._status = status
                self.data = json.dumps(rinfo).encode('ascii')

            @property
            def status(self):
                if isinstance(self._status, (Exception, eventlet.Timeout)):
                    raise self._status
                return self._status

        # all requests fail
        replicate = 'swift.common.db_replicator.ReplConnection.replicate'
        with mock.patch(replicate) as fake_replicate:
            fake_replicate.side_effect = [
                FakeResponse(500, None),
                FakeResponse(500, None),
                FakeResponse(500, None)]
            with mock.patch.object(replicator, 'delete_db') as mock_delete:
                res = replicator._replicate_object('0', db_path, 'node_id')
        self.assertRaises(StopIteration, next, fake_replicate.side_effect)
        self.assertEqual((False, [False, False, False]), res)
        self.assertEqual(0, mock_delete.call_count)
        self.assertFalse(replicator.logger.get_lines_for_level('error'))
        self.assertFalse(replicator.logger.get_lines_for_level('warning'))
        replicator.logger.clear()

        with mock.patch(replicate) as fake_replicate:
            fake_replicate.side_effect = [
                FakeResponse(Exception('ugh'), None),
                FakeResponse(eventlet.Timeout(), None),
                FakeResponse(200, rinfo)]
            with mock.patch.object(replicator, 'delete_db') as mock_delete:
                res = replicator._replicate_object('0', db_path, 'node_id')
        self.assertRaises(StopIteration, next, fake_replicate.side_effect)
        self.assertEqual((False, [False, False, True]), res)
        self.assertEqual(0, mock_delete.call_count)
        lines = replicator.logger.get_lines_for_level('error')
        self.assertIn('ERROR syncing', lines[0])
        self.assertIn('ERROR syncing', lines[1])
        self.assertFalse(lines[2:])
        self.assertFalse(replicator.logger.get_lines_for_level('warning'))
        replicator.logger.clear()

        # partial success
        with mock.patch(replicate) as fake_replicate:
            fake_replicate.side_effect = [
                FakeResponse(200, rinfo),
                FakeResponse(200, rinfo),
                FakeResponse(500, None)]
            with mock.patch.object(replicator, 'delete_db') as mock_delete:
                res = replicator._replicate_object('0', db_path, 'node_id')
        self.assertRaises(StopIteration, next, fake_replicate.side_effect)
        self.assertEqual((False, [True, True, False]), res)
        self.assertEqual(0, mock_delete.call_count)
        self.assertFalse(replicator.logger.get_lines_for_level('error'))
        self.assertFalse(replicator.logger.get_lines_for_level('warning'))
        replicator.logger.clear()

        # 507 triggers additional requests
        with mock.patch(replicate) as fake_replicate:
            fake_replicate.side_effect = [
                FakeResponse(200, rinfo),
                FakeResponse(200, rinfo),
                FakeResponse(507, None),
                FakeResponse(507, None),
                FakeResponse(200, rinfo)]
            with mock.patch.object(replicator, 'delete_db') as mock_delete:
                res = replicator._replicate_object('0', db_path, 'node_id')
        self.assertRaises(StopIteration, next, fake_replicate.side_effect)
        self.assertEqual((False, [True, True, False, False, True]), res)
        self.assertEqual(0, mock_delete.call_count)
        lines = replicator.logger.get_lines_for_level('error')
        self.assertIn('Remote drive not mounted', lines[0])
        self.assertIn('Remote drive not mounted', lines[1])
        self.assertFalse(lines[2:])
        self.assertFalse(replicator.logger.get_lines_for_level('warning'))
        replicator.logger.clear()

        # all requests succeed; node id == 'node_id' causes node to be
        # considered a handoff so expect the db to be deleted
        with mock.patch(replicate) as fake_replicate:
            fake_replicate.side_effect = [
                FakeResponse(200, rinfo),
                FakeResponse(200, rinfo),
                FakeResponse(200, rinfo)]
            with mock.patch.object(replicator, 'delete_db') as mock_delete:
                res = replicator._replicate_object('0', db_path, 'node_id')
        self.assertRaises(StopIteration, next, fake_replicate.side_effect)
        self.assertEqual((True, [True, True, True]), res)
        self.assertEqual(1, mock_delete.call_count)
        self.assertFalse(replicator.logger.get_lines_for_level('error'))
        self.assertFalse(replicator.logger.get_lines_for_level('warning'))

    def test_replicate_object_quarantine(self):
        replicator = ConcreteReplicator({})
        db_path = '/a/b/c/d/e/hey'
        self._patch(patch.object, replicator.brokerclass,
                    'get_repl_missing_table', True)

        def mock_renamer(was, new, fsync=False, cause_colision=False):
            if cause_colision and '-' not in new:
                raise OSError(errno.EEXIST, "File already exists")
            self.assertEqual('/a/b/c/d/e', was)
            if '-' in new:
                self.assertTrue(
                    new.startswith('/a/quarantined/containers/e-'))
            else:
                self.assertEqual('/a/quarantined/containers/e', new)

        def mock_renamer_error(was, new, fsync):
            return mock_renamer(was, new, fsync, cause_colision=True)
        with patch.object(db_replicator, 'renamer', mock_renamer):
            replicator._replicate_object('0', db_path, 'node_id')
        # try the double quarantine
        with patch.object(db_replicator, 'renamer', mock_renamer_error):
            replicator._replicate_object('0', db_path, 'node_id')

    def test_replicate_object_delete_because_deleted(self):
        replicator = ConcreteReplicator({})
        try:
            replicator.delete_db = self.stub_delete_db
            replicator.brokerclass.stub_replication_info = {
                'delete_timestamp': 2, 'put_timestamp': 1}
            replicator._replicate_object('0', '/path/to/file', 'node_id')
        finally:
            replicator.brokerclass.stub_replication_info = None
        self.assertEqual(['/path/to/file'], self.delete_db_calls)

    def test_replicate_object_delete_because_not_shouldbehere(self):
        replicator = ConcreteReplicator({})
        replicator.ring = FakeRingWithNodes().Ring('path')
        replicator.brokerclass = FakeAccountBroker
        replicator._repl_to_node = lambda *args: True
        replicator.delete_db = self.stub_delete_db
        orig_cleanup = replicator.cleanup_post_replicate
        with mock.patch.object(replicator, 'cleanup_post_replicate',
                               side_effect=orig_cleanup) as mock_cleanup:
            replicator._replicate_object('0', '/path/to/file', 'node_id')
        mock_cleanup.assert_called_once_with(mock.ANY, mock.ANY, [True] * 3)
        self.assertIsInstance(mock_cleanup.call_args[0][0],
                              replicator.brokerclass)
        self.assertEqual(['/path/to/file'], self.delete_db_calls)
        self.assertEqual(0, replicator.stats['failure'])

    def test_handoff_delete(self):
        def do_test(config, repl_to_node_results, expect_delete):
            self.delete_db_calls = []
            replicator = ConcreteReplicator(config)
            replicator.ring = FakeRingWithNodes().Ring('path')
            replicator.brokerclass = FakeAccountBroker
            mock_repl_to_node = mock.Mock()
            mock_repl_to_node.side_effect = repl_to_node_results
            replicator._repl_to_node = mock_repl_to_node
            replicator.delete_db = self.stub_delete_db
            orig_cleanup = replicator.cleanup_post_replicate
            with mock.patch.object(replicator, 'cleanup_post_replicate',
                                   side_effect=orig_cleanup) as mock_cleanup:
                replicator._replicate_object('0', '/path/to/file', 'node_id')
            mock_cleanup.assert_called_once_with(mock.ANY, mock.ANY,
                                                 repl_to_node_results)
            self.assertIsInstance(mock_cleanup.call_args[0][0],
                                  replicator.brokerclass)
            if expect_delete:
                self.assertEqual(['/path/to/file'], self.delete_db_calls)
            else:
                self.assertNotEqual(['/path/to/file'], self.delete_db_calls)

            self.assertEqual(repl_to_node_results.count(True),
                             replicator.stats['success'])
            self.assertEqual(repl_to_node_results.count(False),
                             replicator.stats['failure'])

        for cfg, repl_results, expected_delete in (
                # Start with the sanilty check
                ({}, [True] * 3, True),
                ({}, [True, True, False], False),
                ({'handoff_delete': 'auto'}, [True] * 3, True),
                ({'handoff_delete': 'auto'}, [True, True, False], False),
                ({'handoff_delete': 0}, [True] * 3, True),
                ({'handoff_delete': 0}, [True, True, False], False),
                # Now test a lower handoff delete
                ({'handoff_delete': 2}, [True] * 3, True),
                ({'handoff_delete': 2}, [True, True, False], True),
                ({'handoff_delete': 2}, [True, False, False], False),
                ({'handoff_delete': 1}, [True] * 3, True),
                ({'handoff_delete': 1}, [True, True, False], True),
                ({'handoff_delete': 1}, [True, False, False], True),
                # if we configure it too high, handle it gracefully
                ({'handoff_delete': 5}, [True] * 3, True),
        ):
            do_test(cfg, repl_results, expected_delete)

    def test_replicate_object_delete_delegated_to_cleanup_post_replicate(self):
        replicator = ConcreteReplicator({})
        replicator.ring = FakeRingWithNodes().Ring('path')
        replicator.brokerclass = FakeAccountBroker
        replicator._repl_to_node = lambda *args: True
        replicator.delete_db = self.stub_delete_db

        # cleanup succeeds
        with mock.patch.object(replicator, 'cleanup_post_replicate',
                               return_value=True) as mock_cleanup:
            replicator._replicate_object('0', '/path/to/file', 'node_id')
        mock_cleanup.assert_called_once_with(mock.ANY, mock.ANY, [True] * 3)
        self.assertIsInstance(mock_cleanup.call_args[0][0],
                              replicator.brokerclass)
        self.assertFalse(self.delete_db_calls)
        self.assertEqual(0, replicator.stats['failure'])
        self.assertEqual(3, replicator.stats['success'])

        # cleanup fails
        replicator._zero_stats()
        with mock.patch.object(replicator, 'cleanup_post_replicate',
                               return_value=False) as mock_cleanup:
            replicator._replicate_object('0', '/path/to/file', 'node_id')
        mock_cleanup.assert_called_once_with(mock.ANY, mock.ANY, [True] * 3)
        self.assertIsInstance(mock_cleanup.call_args[0][0],
                              replicator.brokerclass)
        self.assertFalse(self.delete_db_calls)
        self.assertEqual(3, replicator.stats['failure'])
        self.assertEqual(0, replicator.stats['success'])

        # shouldbehere True - cleanup not required
        replicator._zero_stats()
        primary_node_id = replicator.ring.get_part_nodes('0')[0]['id']
        with mock.patch.object(replicator, 'cleanup_post_replicate',
                               return_value=True) as mock_cleanup:
            replicator._replicate_object('0', '/path/to/file', primary_node_id)
        mock_cleanup.assert_not_called()
        self.assertFalse(self.delete_db_calls)
        self.assertEqual(0, replicator.stats['failure'])
        self.assertEqual(2, replicator.stats['success'])

    def test_cleanup_post_replicate(self):
        replicator = ConcreteReplicator({}, logger=self.logger)
        replicator.ring = FakeRingWithNodes().Ring('path')
        broker = FakeBroker(account='a', container='c')
        replicator._repl_to_node = lambda *args: True
        info = broker.get_replication_info()

        with mock.patch.object(replicator, 'delete_db') as mock_delete_db:
            res = replicator.cleanup_post_replicate(
                broker, info, [False] * 3)
        mock_delete_db.assert_not_called()
        self.assertTrue(res)
        self.assertEqual(['Not deleting db (0/3 success), path: %s, '
                          'db: %s' % (quote(broker.path), broker.db_file)],
                         replicator.logger.get_lines_for_level('debug'))
        replicator.logger.clear()

        with mock.patch.object(replicator, 'delete_db') as mock_delete_db:
            res = replicator.cleanup_post_replicate(
                broker, info, [True, False, True])
        mock_delete_db.assert_not_called()
        self.assertTrue(res)
        self.assertEqual(['Not deleting db (2/3 success), path: %s, '
                          'db: %s' % (quote(broker.path), broker.db_file)],
                         replicator.logger.get_lines_for_level('debug'))
        replicator.logger.clear()

        broker.stub_replication_info = {'max_row': 101}
        with mock.patch.object(replicator, 'delete_db') as mock_delete_db:
            res = replicator.cleanup_post_replicate(
                broker, info, [True] * 3)
        mock_delete_db.assert_not_called()
        self.assertTrue(res)
        self.assertEqual(['Not deleting db (2 new rows), path: %s, '
                          'db: %s' % (quote(broker.path), broker.db_file)],
                         replicator.logger.get_lines_for_level('debug'))
        replicator.logger.clear()

        broker.stub_replication_info = {'max_row': 98}
        with mock.patch.object(replicator, 'delete_db') as mock_delete_db:
            res = replicator.cleanup_post_replicate(
                broker, info, [True] * 3)
        mock_delete_db.assert_not_called()
        self.assertTrue(res)
        broker.stub_replication_info = None
        self.assertEqual(['Not deleting db (negative max_row_delta: -1),'
                          ' path: %s, db: %s' % (quote(broker.path),
                                                 broker.db_file)],
                         replicator.logger.get_lines_for_level('error'))
        replicator.logger.clear()

        with mock.patch.object(replicator, 'delete_db') as mock_delete_db:
            res = replicator.cleanup_post_replicate(
                broker, info, [True] * 3)
        mock_delete_db.assert_called_once_with(broker)
        self.assertTrue(res)
        self.assertEqual(['Successfully deleted db, path: %s, db: %s' %
                          (quote(broker.path), broker.db_file)],
                         replicator.logger.get_lines_for_level('debug'))
        replicator.logger.clear()

        with mock.patch.object(replicator, 'delete_db',
                               return_value=False) as mock_delete_db:
            res = replicator.cleanup_post_replicate(
                broker, info, [True] * 3)
        mock_delete_db.assert_called_once_with(broker)
        self.assertFalse(res)
        self.assertEqual(['Failed to delete db, path: %s, db: %s' %
                          (quote(broker.path), broker.db_file)],
                         replicator.logger.get_lines_for_level('debug'))
        replicator.logger.clear()

    def test_replicate_object_with_exception(self):
        replicator = ConcreteReplicator({})
        replicator.ring = FakeRingWithNodes().Ring('path')
        replicator.brokerclass = FakeAccountBroker
        replicator.delete_db = self.stub_delete_db
        replicator._repl_to_node = mock.Mock(side_effect=Exception())
        replicator._replicate_object('0', '/path/to/file',
                                     replicator.ring.devs[0]['id'])
        self.assertEqual(2, replicator._repl_to_node.call_count)
        # with one DriveNotMounted exception called on +1 more replica
        replicator._repl_to_node = mock.Mock(side_effect=[DriveNotMounted()])
        replicator._replicate_object('0', '/path/to/file',
                                     replicator.ring.devs[0]['id'])
        self.assertEqual(3, replicator._repl_to_node.call_count)
        # called on +1 more replica and self when *first* handoff
        replicator._repl_to_node = mock.Mock(side_effect=[DriveNotMounted()])
        replicator._replicate_object('0', '/path/to/file',
                                     replicator.ring.devs[3]['id'])
        self.assertEqual(4, replicator._repl_to_node.call_count)
        # even if it's the last handoff it works to keep 3 replicas
        # 2 primaries + 1 handoff
        replicator._repl_to_node = mock.Mock(side_effect=[DriveNotMounted()])
        replicator._replicate_object('0', '/path/to/file',
                                     replicator.ring.devs[-1]['id'])
        self.assertEqual(4, replicator._repl_to_node.call_count)
        # with two DriveNotMounted exceptions called on +2 more replica keeping
        # durability
        replicator._repl_to_node = mock.Mock(
            side_effect=[DriveNotMounted()] * 2)
        replicator._replicate_object('0', '/path/to/file',
                                     replicator.ring.devs[0]['id'])
        self.assertEqual(4, replicator._repl_to_node.call_count)

    def test_replicate_object_with_exception_run_out_of_nodes(self):
        replicator = ConcreteReplicator({})
        replicator.ring = FakeRingWithNodes().Ring('path')
        replicator.brokerclass = FakeAccountBroker
        replicator.delete_db = self.stub_delete_db
        # all other devices are not mounted
        replicator._repl_to_node = mock.Mock(side_effect=DriveNotMounted())
        replicator._replicate_object('0', '/path/to/file',
                                     replicator.ring.devs[0]['id'])
        self.assertEqual(5, replicator._repl_to_node.call_count)

    def test_replicate_account_out_of_place(self):
        replicator = ConcreteReplicator({}, logger=self.logger)
        replicator.ring = FakeRingWithNodes().Ring('path')
        replicator.brokerclass = FakeAccountBroker
        replicator._repl_to_node = lambda *args: True
        replicator.delete_db = self.stub_delete_db
        # Correct node_id, wrong part
        part = replicator.ring.get_part(TEST_ACCOUNT_NAME) + 1
        node_id = replicator.ring.get_part_nodes(part)[0]['id']
        replicator._replicate_object(str(part), '/path/to/file', node_id)
        self.assertEqual(['/path/to/file'], self.delete_db_calls)
        error_msgs = replicator.logger.get_lines_for_level('error')
        expected = ('Found db that should be on '
                    'partition 0; will replicate out and remove,'
                    ' path: a%20c%20t, db: /path/to/file')
        self.assertEqual(error_msgs, [expected])

    def test_replicate_container_out_of_place(self):
        replicator = ConcreteReplicator({}, logger=self.logger)
        replicator.ring = FakeRingWithNodes().Ring('path')
        replicator._repl_to_node = lambda *args: True
        replicator.delete_db = self.stub_delete_db
        # Correct node_id, wrong part
        part = replicator.ring.get_part(
            TEST_ACCOUNT_NAME, TEST_CONTAINER_NAME) + 1
        node_id = replicator.ring.get_part_nodes(part)[0]['id']
        replicator._replicate_object(str(part), '/path/to/file', node_id)
        self.assertEqual(['/path/to/file'], self.delete_db_calls)
        self.assertEqual(
            replicator.logger.get_lines_for_level('error'),
            ['Found db that should '
             'be on partition 0; will replicate out and remove,'
             ' path: a%20c%20t/c%20o%20n, db: /path/to/file'])

    def test_replicate_container_out_of_place_no_node(self):
        replicator = ConcreteReplicator({}, logger=self.logger)
        replicator.ring = FakeRingWithSingleNode().Ring('path')
        replicator._repl_to_node = lambda *args: True

        replicator.delete_db = self.stub_delete_db
        # Correct node_id, wrong part
        part = replicator.ring.get_part(
            TEST_ACCOUNT_NAME, TEST_CONTAINER_NAME) + 1
        node_id = replicator.ring.get_part_nodes(part)[0]['id']
        replicator._replicate_object(str(part), '/path/to/file', node_id)
        self.assertEqual(['/path/to/file'], self.delete_db_calls)

        self.delete_db_calls = []

        # No nodes this time
        replicator.ring.get_part_nodes = lambda *args: []
        replicator.delete_db = self.stub_delete_db
        # Correct node_id, wrong part
        part = replicator.ring.get_part(
            TEST_ACCOUNT_NAME, TEST_CONTAINER_NAME) + 1
        replicator._replicate_object(str(part), '/path/to/file', node_id)
        self.assertEqual([], self.delete_db_calls)

    def test_replicate_object_different_region(self):
        db_replicator.ring = FakeRingWithNodes()
        replicator = ConcreteReplicator({})
        replicator._repl_to_node = mock.Mock()
        # For node_id = 1, one replica in same region(1) and other is in a
        # different region(2). Refer: FakeRingWithNodes
        replicator._replicate_object('0', '/path/to/file', 1)
        # different_region was set True and passed to _repl_to_node()
        self.assertEqual(replicator._repl_to_node.call_args_list[0][0][-1],
                         True)
        # different_region was set False and passed to _repl_to_node()
        self.assertEqual(replicator._repl_to_node.call_args_list[1][0][-1],
                         False)

    def test_delete_db(self):
        db_replicator.lock_parent_directory = lock_parent_directory
        replicator = ConcreteReplicator({}, logger=self.logger)
        replicator._zero_stats()
        replicator.extract_device = lambda _: 'some_device'

        temp_part_dir = os.path.join(self.temp_dir, '140')
        os.mkdir(temp_part_dir)
        temp_suf_dir = os.path.join(temp_part_dir, '16e')
        os.mkdir(temp_suf_dir)
        temp_hash_dir = os.path.join(temp_suf_dir,
                                     '166e33924a08ede4204871468c11e16e')
        os.mkdir(temp_hash_dir)
        temp_file = NamedTemporaryFile(dir=temp_hash_dir, delete=False)
        temp_hash_dir2 = os.path.join(temp_suf_dir,
                                      '266e33924a08ede4204871468c11e16e')
        os.mkdir(temp_hash_dir2)
        temp_file2 = NamedTemporaryFile(dir=temp_hash_dir2, delete=False)

        # sanity-checks
        self.assertTrue(os.path.exists(self.temp_dir))
        self.assertTrue(os.path.exists(temp_part_dir))
        self.assertTrue(os.path.exists(temp_suf_dir))
        self.assertTrue(os.path.exists(temp_hash_dir))
        self.assertTrue(os.path.exists(temp_file.name))
        self.assertTrue(os.path.exists(temp_hash_dir2))
        self.assertTrue(os.path.exists(temp_file2.name))
        self.assertEqual(0, replicator.stats['remove'])

        temp_file.db_file = temp_file.name
        replicator.delete_db(temp_file)

        self.assertTrue(os.path.exists(self.temp_dir))
        self.assertTrue(os.path.exists(temp_part_dir))
        self.assertTrue(os.path.exists(temp_suf_dir))
        self.assertFalse(os.path.exists(temp_hash_dir))
        self.assertFalse(os.path.exists(temp_file.name))
        self.assertTrue(os.path.exists(temp_hash_dir2))
        self.assertTrue(os.path.exists(temp_file2.name))
        self.assertEqual(
            [(('removes.some_device',), {})],
            replicator.logger.statsd_client.calls['increment'])
        self.assertEqual(1, replicator.stats['remove'])

        temp_file2.db_file = temp_file2.name
        replicator.delete_db(temp_file2)

        self.assertTrue(os.path.exists(self.temp_dir))
        self.assertFalse(os.path.exists(temp_part_dir))
        self.assertFalse(os.path.exists(temp_suf_dir))
        self.assertFalse(os.path.exists(temp_hash_dir))
        self.assertFalse(os.path.exists(temp_file.name))
        self.assertFalse(os.path.exists(temp_hash_dir2))
        self.assertFalse(os.path.exists(temp_file2.name))
        self.assertEqual(
            [(('removes.some_device',), {})] * 2,
            replicator.logger.statsd_client.calls['increment'])
        self.assertEqual(2, replicator.stats['remove'])

    def test_extract_device(self):
        replicator = ConcreteReplicator({'devices': '/some/root'})
        self.assertEqual('some_device', replicator.extract_device(
            '/some/root/some_device/deeper/and/deeper'))
        self.assertEqual('UNKNOWN', replicator.extract_device(
            '/some/foo/some_device/deeper/and/deeper'))

    def test_dispatch_no_arg_pop(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)
        with unit.mock_check_drive(isdir=True):
            response = rpc.dispatch(('a',), 'arg')
        self.assertEqual(b'Invalid object type', response.body)
        self.assertEqual(400, response.status_int)

    def test_dispatch_drive_not_mounted(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=True)

        with unit.mock_check_drive() as mocks:
            response = rpc.dispatch(('drive', 'part', 'hash'), ['method'])
        self.assertEqual([mock.call(os.path.join('/drive'))],
                         mocks['ismount'].call_args_list)

        self.assertEqual('507 drive is not mounted', response.status)
        self.assertEqual(507, response.status_int)

    def test_dispatch_unexpected_operation_db_does_not_exist(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)

        def mock_mkdirs(path):
            self.assertEqual('/drive/tmp', path)

        self._patch(patch.object, db_replicator, 'mkdirs', mock_mkdirs)

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.return_value = False
            response = rpc.dispatch(('drive', 'part', 'hash'), ['unexpected'])

        self.assertEqual('404 Not Found', response.status)
        self.assertEqual(404, response.status_int)

    def test_dispatch_operation_unexpected(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)

        self._patch(patch.object, db_replicator, 'mkdirs', lambda *args: True)

        def unexpected_method(broker, args):
            self.assertEqual(FakeBroker, broker.__class__)
            self.assertEqual(['arg1', 'arg2'], args)
            return 'unexpected-called'

        rpc.unexpected = unexpected_method

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.return_value = True
            response = rpc.dispatch(('drive', 'part', 'hash'),
                                    ['unexpected', 'arg1', 'arg2'])
            mock_os.path.exists.assert_called_with('/part/ash/hash/hash.db')

        self.assertEqual('unexpected-called', response)

    def test_dispatch_operation_rsync_then_merge(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)

        self._patch(patch.object, db_replicator, 'renamer', lambda *args: True)

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.return_value = True
            response = rpc.dispatch(('drive', 'part', 'hash'),
                                    ['rsync_then_merge', 'arg1', 'arg2'])
            expected_calls = [call('/part/ash/hash/hash.db'),
                              call('/drive/tmp/arg1'),
                              call('/part/ash/hash/hash.db'),
                              call('/drive/tmp/arg1')]
            self.assertEqual(mock_os.path.exists.call_args_list,
                             expected_calls)
            self.assertEqual('204 No Content', response.status)
            self.assertEqual(204, response.status_int)

    def test_dispatch_operation_complete_rsync(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)

        self._patch(patch.object, db_replicator, 'renamer', lambda *args: True)

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.side_effect = [False, True]
            response = rpc.dispatch(('drive', 'part', 'hash'),
                                    ['complete_rsync', 'arg1'])
            expected_calls = [call('/part/ash/hash/hash.db'),
                              call('/drive/tmp/arg1')]
            self.assertEqual(mock_os.path.exists.call_args_list,
                             expected_calls)
            self.assertEqual('204 No Content', response.status)
            self.assertEqual(204, response.status_int)

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.side_effect = [False, True]
            response = rpc.dispatch(('drive', 'part', 'hash'),
                                    ['complete_rsync', 'arg1', 'arg2'])
            expected_calls = [call('/part/ash/hash/arg2'),
                              call('/drive/tmp/arg1')]
            self.assertEqual(mock_os.path.exists.call_args_list,
                             expected_calls)
            self.assertEqual('204 No Content', response.status)
            self.assertEqual(204, response.status_int)

    def test_delete_db_exception_log_includes_parent_directory(self):
        replicator = ConcreteReplicator({}, logger=self.logger)
        replicator._zero_stats()
        db_replicator.lock_parent_directory = lock_parent_directory
        temp_part_dir = os.path.join(self.temp_dir, '140')
        os.mkdir(temp_part_dir)
        suf_dir = os.path.join(temp_part_dir, '16e')
        os.mkdir(suf_dir)
        hash_prefix = '166e33924a08ede4204871468c11e16e'
        hash_dir = os.path.join(suf_dir, hash_prefix)
        os.mkdir(hash_dir)
        object_file = os.path.join(hash_dir, hash_prefix + '.db')
        with open(object_file, 'w'):
            pass

        broker = FakeBroker(account='a', container='c')
        broker.db_file = object_file
        parent_dir = suf_dir

        def rmdir_side_effect(path):
            if path == parent_dir:
                raise OSError(errno.EPERM, "Operation not permitted")
            return os.rmdir(path)

        with mock.patch('swift.common.db_replicator.os',
                        wraps=os) as mock_os:
            mock_os.rmdir.side_effect = rmdir_side_effect
            result = replicator.delete_db(broker)
        self.assertFalse(result)
        self.assertFalse(os.path.exists(hash_dir))
        self.assertFalse(os.path.exists(object_file))

        lines = self.logger.get_lines_for_level('error')
        self.assertEqual(
            ['ERROR while trying to clean up %s, path: %s, db: %s: ' %
             (parent_dir, db_replicator.quote(broker.path),
              broker.db_file)],
            lines)

    def test_rsync_then_merge_db_does_not_exist(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.return_value = False
            response = rpc.rsync_then_merge('drive', '/data/db.db',
                                            ('arg1', 'arg2'))
            mock_os.path.exists.assert_called_with('/data/db.db')
            self.assertEqual('404 Not Found', response.status)
            self.assertEqual(404, response.status_int)

    def test_rsync_then_merge_old_does_not_exist(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.side_effect = [True, False]
            response = rpc.rsync_then_merge('drive', '/data/db.db',
                                            ('arg1', 'arg2'))
            expected_calls = [call('/data/db.db'), call('/drive/tmp/arg1')]
            self.assertEqual(mock_os.path.exists.call_args_list,
                             expected_calls)
            self.assertEqual('404 Not Found', response.status)
            self.assertEqual(404, response.status_int)

    def test_rsync_then_merge_with_objects(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)
        target_db = '/data/db.db'

        def mock_renamer(old, new):
            self.assertEqual('/drive/tmp/arg1', old)
            self.assertEqual(target_db, new)

        self._patch(patch.object, db_replicator, 'renamer', mock_renamer)

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.return_value = True
            response = rpc.rsync_then_merge('drive', target_db,
                                            ['arg1', 'arg2'])
            self.assertEqual('204 No Content', response.status)
            self.assertEqual(204, response.status_int)

    def test_complete_rsync_db_exists(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.return_value = True
            response = rpc.complete_rsync('drive', '/data/db.db', ['arg1'])
            mock_os.path.exists.assert_called_with('/data/db.db')
            self.assertEqual('404 Not Found', response.status)
            self.assertEqual(404, response.status_int)

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.return_value = True
            response = rpc.complete_rsync('drive', '/data/db.db',
                                          ['arg1', 'arg2'])
            mock_os.path.exists.assert_called_with('/data/arg2')
            self.assertEqual('404 Not Found', response.status)
            self.assertEqual(404, response.status_int)

    def test_complete_rsync_old_file_does_not_exist(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.return_value = False
            response = rpc.complete_rsync('drive', '/data/db.db',
                                          ['arg1'])
            expected_calls = [call('/data/db.db'), call('/drive/tmp/arg1')]
            self.assertEqual(expected_calls,
                             mock_os.path.exists.call_args_list)
            self.assertEqual('404 Not Found', response.status)
            self.assertEqual(404, response.status_int)

        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.return_value = False
            response = rpc.complete_rsync('drive', '/data/db.db',
                                          ['arg1', 'arg2'])
            expected_calls = [call('/data/arg2'), call('/drive/tmp/arg1')]
            self.assertEqual(expected_calls,
                             mock_os.path.exists.call_args_list)
            self.assertEqual('404 Not Found', response.status)
            self.assertEqual(404, response.status_int)

    def test_complete_rsync_rename(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)

        def mock_renamer(old, new):
            renamer_calls.append((old, new))

        self._patch(patch.object, db_replicator, 'renamer', mock_renamer)

        renamer_calls = []
        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.side_effect = [False, True]
            response = rpc.complete_rsync('drive', '/data/db.db',
                                          ['arg1'])
        self.assertEqual('204 No Content', response.status)
        self.assertEqual(204, response.status_int)
        self.assertEqual(('/drive/tmp/arg1', '/data/db.db'), renamer_calls[0])
        self.assertFalse(renamer_calls[1:])

        renamer_calls = []
        with patch('swift.common.db_replicator.os',
                   new=mock.MagicMock(wraps=os)) as mock_os, \
                unit.mock_check_drive(isdir=True):
            mock_os.path.exists.side_effect = [False, True]
            response = rpc.complete_rsync('drive', '/data/db.db',
                                          ['arg1', 'arg2'])
        self.assertEqual('204 No Content', response.status)
        self.assertEqual(204, response.status_int)
        self.assertEqual(('/drive/tmp/arg1', '/data/arg2'), renamer_calls[0])
        self.assertFalse(renamer_calls[1:])

    def test_replicator_sync_with_broker_replication_missing_table(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False,
                                          logger=self.logger)
        broker = FakeBroker()
        broker.get_repl_missing_table = True

        called = []

        def mock_quarantine_db(object_file, server_type):
            called.append(True)
            self.assertEqual(broker.db_file, object_file)
            self.assertEqual(broker.db_type, server_type)

        self._patch(patch.object, db_replicator, 'quarantine_db',
                    mock_quarantine_db)

        with unit.mock_check_drive(isdir=True):
            response = rpc.sync(broker, ('remote_sync', 'hash_', 'id_',
                                         'created_at', 'put_timestamp',
                                         'delete_timestamp', 'metadata'))

        self.assertEqual('404 Not Found', response.status)
        self.assertEqual(404, response.status_int)
        self.assertEqual(called, [True])
        errors = rpc.logger.get_lines_for_level('error')
        self.assertEqual(errors,
                         ["Unable to decode remote metadata 'metadata'",
                          "Quarantining DB, path: %s, db: %s" %
                          (quote(broker.path), broker.db_file)])

    def test_replicator_sync(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)
        broker = FakeBroker()

        with unit.mock_check_drive(isdir=True):
            response = rpc.sync(broker, (
                broker.get_sync() + 1, 12345, 'id_',
                'created_at', 'put_timestamp', 'delete_timestamp',
                '{"meta1": "data1", "meta2": "data2"}'))

        self.assertEqual({'meta1': 'data1', 'meta2': 'data2'},
                         broker.metadata)
        self.assertEqual('created_at', broker.created_at)
        self.assertEqual('put_timestamp', broker.put_timestamp)
        self.assertEqual('delete_timestamp', broker.delete_timestamp)

        self.assertEqual('200 OK', response.status)
        self.assertEqual(200, response.status_int)

    def test_rsync_then_merge(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)
        with unit.mock_check_drive(isdir=True):
            rpc.rsync_then_merge('sda1', '/srv/swift/blah', ('a', 'b'))

    def test_merge_items(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)
        fake_broker = FakeBroker()
        args = ('a', 'b')
        with unit.mock_check_drive(isdir=True):
            rpc.merge_items(fake_broker, args)
        self.assertEqual(fake_broker.args, args)

    def test_merge_syncs(self):
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)
        fake_broker = FakeBroker()
        args = ('a', 'b')
        with unit.mock_check_drive(isdir=True):
            rpc.merge_syncs(fake_broker, args)
        self.assertEqual(fake_broker.args, (args[0],))

    def test_complete_rsync_with_bad_input(self):
        drive = '/some/root'
        db_file = __file__
        args = ['old_file']
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)
        with unit.mock_check_drive(isdir=True):
            resp = rpc.complete_rsync(drive, db_file, args)
        self.assertIsInstance(resp, HTTPException)
        self.assertEqual(404, resp.status_int)
        with unit.mock_check_drive(isdir=True):
            resp = rpc.complete_rsync(drive, 'new_db_file', args)
        self.assertIsInstance(resp, HTTPException)
        self.assertEqual(404, resp.status_int)

    def test_complete_rsync(self):
        drive = self.temp_dir
        args = ['old_file']
        rpc = db_replicator.ReplicatorRpc('/', '/', FakeBroker,
                                          mount_check=False)
        os.mkdir('%s/tmp' % drive)
        old_file = '%s/tmp/old_file' % drive
        new_file = '%s/new_db_file' % drive
        fp = open(old_file, 'w')
        fp.write('void')
        fp.close
        resp = rpc.complete_rsync(drive, new_file, args)
        self.assertEqual(204, resp.status_int)

    @unit.with_tempdir
    def test_empty_suffix_and_hash_dirs_get_cleanedup(self, tempdir):
        datadir = os.path.join(tempdir, 'containers')
        db_path = ('450/afd/7089ab48d955ab0851fc51cc17a34afd/'
                   '7089ab48d955ab0851fc51cc17a34afd.db')
        random_file = ('1060/xyz/1234ab48d955ab0851fc51cc17a34xyz/'
                       '1234ab48d955ab0851fc51cc17a34xyz.abc')

        # trailing "/" indicates empty dir
        paths = [
            # empty part dir
            '240/',
            # empty suffix dir
            '18/aba/',
            # empty hashdir
            '1054/27e/d41d8cd98f00b204e9800998ecf8427e/',
            # database
            db_path,
            # non database file
            random_file,
        ]
        for path in paths:
            path = os.path.join(datadir, path)
            os.makedirs(os.path.dirname(path))
            if os.path.basename(path):
                # our setup requires "directories" to end in "/" (i.e. basename
                # is ''); otherwise, create an empty file
                open(path, 'w')
        # sanity
        self.assertEqual({'240', '18', '1054', '1060', '450'},
                         set(os.listdir(datadir)))
        for path in paths:
            dirpath = os.path.join(datadir, os.path.dirname(path))
            self.assertTrue(os.path.isdir(dirpath))

        node_id = 1
        results = list(db_replicator.roundrobin_datadirs(
            [(datadir, node_id, lambda p: True)]))
        expected = [
            ('450', os.path.join(datadir, db_path), node_id),
        ]
        self.assertEqual(results, expected)

        # all the empty leaf dirs are cleaned up
        for path in paths:
            if os.path.basename(path):
                check = self.assertTrue
            else:
                check = self.assertFalse
            dirpath = os.path.join(datadir, os.path.dirname(path))
            isdir = os.path.isdir(dirpath)
            check(isdir, '%r is%s a directory!' % (
                dirpath, '' if isdir else ' not'))

        # despite the leaves cleaned up it takes a few loops to finish it off
        self.assertEqual({'18', '1054', '1060', '450'},
                         set(os.listdir(datadir)))

        results = list(db_replicator.roundrobin_datadirs(
            [(datadir, node_id, lambda p: True)]))
        self.assertEqual(results, expected)
        self.assertEqual({'1054', '1060', '450'},
                         set(os.listdir(datadir)))

        results = list(db_replicator.roundrobin_datadirs(
            [(datadir, node_id, lambda p: True)]))
        self.assertEqual(results, expected)
        # non db file in '1060' dir is not deleted and exception is handled
        self.assertEqual({'1060', '450'},
                         set(os.listdir(datadir)))

    def test_roundrobin_datadirs(self):
        listdir_calls = []
        isdir_calls = []
        exists_calls = []
        shuffle_calls = []
        rmdir_calls = []

        def _listdir(path):
            listdir_calls.append(path)
            if not path.startswith('/srv/node/sda/containers') and \
                    not path.startswith('/srv/node/sdb/containers'):
                return []
            path = path[len('/srv/node/sdx/containers'):]
            if path == '':
                return ['123', '456', '789', '9999', "-5", "not-a-partition"]
                # 456 will pretend to be a file
                # 9999 will be an empty partition with no contents
                # -5 and not-a-partition were created by something outside
                #   Swift
            elif path == '/123':
                return ['abc', 'def.db']  # def.db will pretend to be a file
            elif path == '/123/abc':
                # 11111111111111111111111111111abc will pretend to be a file
                return ['00000000000000000000000000000abc',
                        '11111111111111111111111111111abc']
            elif path == '/123/abc/00000000000000000000000000000abc':
                return ['00000000000000000000000000000abc.db',
                        # This other.db isn't in the right place, so should be
                        # ignored later.
                        '000000000000000000000000000other.db',
                        'weird1']  # weird1 will pretend to be a dir, if asked
            elif path == '/789':
                return ['ghi', 'jkl']  # jkl will pretend to be a file
            elif path == '/789/ghi':
                # 33333333333333333333333333333ghi will pretend to be a file
                return ['22222222222222222222222222222ghi',
                        '33333333333333333333333333333ghi']
            elif path == '/789/ghi/22222222222222222222222222222ghi':
                return ['22222222222222222222222222222ghi.db',
                        'weird2']  # weird2 will pretend to be a dir, if asked
            elif path == '9999':
                return []
            elif path == 'not-a-partition':
                raise Exception("shouldn't look in not-a-partition")
            elif path == '-5':
                raise Exception("shouldn't look in -5")
            return []

        def _isdir(path):
            isdir_calls.append(path)
            if not path.startswith('/srv/node/sda/containers') and \
                    not path.startswith('/srv/node/sdb/containers'):
                return False
            path = path[len('/srv/node/sdx/containers'):]
            if path in ('/123', '/123/abc',
                        '/123/abc/00000000000000000000000000000abc',
                        '/123/abc/00000000000000000000000000000abc/weird1',
                        '/789', '/789/ghi',
                        '/789/ghi/22222222222222222222222222222ghi',
                        '/789/ghi/22222222222222222222222222222ghi/weird2',
                        '/9999'):
                return True
            return False

        def _exists(arg):
            exists_calls.append(arg)
            return True

        def _shuffle(arg):
            shuffle_calls.append(arg)

        def _rmdir(arg):
            rmdir_calls.append(arg)

        base = 'swift.common.db_replicator.'
        with mock.patch(base + 'os.listdir', _listdir), \
                mock.patch(base + 'os.path.isdir', _isdir), \
                mock.patch(base + 'os.path.exists', _exists), \
                mock.patch(base + 'random.shuffle', _shuffle), \
                mock.patch(base + 'os.rmdir', _rmdir):

            datadirs = [('/srv/node/sda/containers', 1, lambda p: True),
                        ('/srv/node/sdb/containers', 2, lambda p: True)]
            results = list(db_replicator.roundrobin_datadirs(datadirs))
            # The results show that the .db files are returned, the devices
            # interleaved.
            self.assertEqual(results, [
                ('123', '/srv/node/sda/containers/123/abc/'
                        '00000000000000000000000000000abc/'
                        '00000000000000000000000000000abc.db', 1),
                ('123', '/srv/node/sdb/containers/123/abc/'
                        '00000000000000000000000000000abc/'
                        '00000000000000000000000000000abc.db', 2),
                ('789', '/srv/node/sda/containers/789/ghi/'
                        '22222222222222222222222222222ghi/'
                        '22222222222222222222222222222ghi.db', 1),
                ('789', '/srv/node/sdb/containers/789/ghi/'
                        '22222222222222222222222222222ghi/'
                        '22222222222222222222222222222ghi.db', 2)])
            # The listdir calls show that we only listdir the dirs
            self.assertEqual(listdir_calls, [
                '/srv/node/sda/containers',
                '/srv/node/sda/containers/123',
                '/srv/node/sda/containers/123/abc',
                '/srv/node/sdb/containers',
                '/srv/node/sdb/containers/123',
                '/srv/node/sdb/containers/123/abc',
                '/srv/node/sda/containers/789',
                '/srv/node/sda/containers/789/ghi',
                '/srv/node/sdb/containers/789',
                '/srv/node/sdb/containers/789/ghi',
                '/srv/node/sda/containers/9999',
                '/srv/node/sdb/containers/9999'])
            # The isdir calls show that we did ask about the things pretending
            # to be files at various levels.
            self.assertEqual(isdir_calls, [
                '/srv/node/sda/containers/123',
                '/srv/node/sda/containers/123/abc',
                ('/srv/node/sda/containers/123/abc/'
                 '00000000000000000000000000000abc'),
                '/srv/node/sdb/containers/123',
                '/srv/node/sdb/containers/123/abc',
                ('/srv/node/sdb/containers/123/abc/'
                 '00000000000000000000000000000abc'),
                ('/srv/node/sda/containers/123/abc/'
                 '11111111111111111111111111111abc'),
                '/srv/node/sda/containers/123/def.db',
                '/srv/node/sda/containers/456',
                '/srv/node/sda/containers/789',
                '/srv/node/sda/containers/789/ghi',
                ('/srv/node/sda/containers/789/ghi/'
                 '22222222222222222222222222222ghi'),
                ('/srv/node/sdb/containers/123/abc/'
                 '11111111111111111111111111111abc'),
                '/srv/node/sdb/containers/123/def.db',
                '/srv/node/sdb/containers/456',
                '/srv/node/sdb/containers/789',
                '/srv/node/sdb/containers/789/ghi',
                ('/srv/node/sdb/containers/789/ghi/'
                 '22222222222222222222222222222ghi'),
                ('/srv/node/sda/containers/789/ghi/'
                 '33333333333333333333333333333ghi'),
                '/srv/node/sda/containers/789/jkl',
                '/srv/node/sda/containers/9999',
                ('/srv/node/sdb/containers/789/ghi/'
                 '33333333333333333333333333333ghi'),
                '/srv/node/sdb/containers/789/jkl',
                '/srv/node/sdb/containers/9999'])
            # The exists calls are the .db files we looked for as we walked the
            # structure.
            self.assertEqual(exists_calls, [
                ('/srv/node/sda/containers/123/abc/'
                 '00000000000000000000000000000abc/'
                 '00000000000000000000000000000abc.db'),
                ('/srv/node/sdb/containers/123/abc/'
                 '00000000000000000000000000000abc/'
                 '00000000000000000000000000000abc.db'),
                ('/srv/node/sda/containers/789/ghi/'
                 '22222222222222222222222222222ghi/'
                 '22222222222222222222222222222ghi.db'),
                ('/srv/node/sdb/containers/789/ghi/'
                 '22222222222222222222222222222ghi/'
                 '22222222222222222222222222222ghi.db')])
            # Shows that we called shuffle twice, once for each device.
            self.assertEqual(
                shuffle_calls, [['123', '456', '789', '9999'],
                                ['123', '456', '789', '9999']])

            # Shows that we called removed the two empty partition directories.
            self.assertEqual(
                rmdir_calls, ['/srv/node/sda/containers/9999',
                              '/srv/node/sdb/containers/9999'])

    @mock.patch("swift.common.db_replicator.ReplConnection", mock.Mock())
    def test_http_connect(self):
        node = "node"
        partition = "partition"
        db_file = __file__
        replicator = ConcreteReplicator({})
        replicator._http_connect(node, partition, db_file)
        expected_hsh = os.path.basename(db_file).split('.', 1)[0]
        expected_hsh = expected_hsh.split('_', 1)[0]
        db_replicator.ReplConnection.assert_has_calls([
            mock.call(node, partition, expected_hsh, replicator.logger)])

    @unit.with_tempdir
    def test_reclaim_tmp_files(self, tmpdir):
        db_dir = os.path.join(tmpdir, 'containers/123/bla/some_bla/localtion/')
        mkdirs(db_dir)

        # Touch some files
        file_mtimes = {'the_db.db': 0,
                       'somethingelse.txt': 0,
                       'some_temp_file.tmp': 5,
                       'another_tmp_file.db.tmp': 10,
                       'one_last_temp.db.tmp': 5}
        for f in file_mtimes.keys():
            with open(os.path.join(db_dir, f), 'w'):
                pass

        # create a broker
        broker = FakeBroker(os.path.join(db_dir, 'the_db.db'))
        calls = []

        def fake_getmtime(path, *args, **kwargs):
            calls.append(path)
            return file_mtimes.get(os.path.basename(path), 0)

        # OK now let's try and reclaim old tmp files
        with mock.patch('os.path.getmtime', fake_getmtime):
            replicator = ConcreteReplicator({'reclaim_age': 10})
            replicator._reclaim_tmp_dbs(broker, 18)

        # It should only look at the tmp files and because of the mtimes only
        # 2 of them should have been removed
        self.assertEqual(sorted(['another_tmp_file.db.tmp',
                                 'somethingelse.txt',
                                 'the_db.db']),
                         sorted(listdir(db_dir)))
        expected_paths_called = [
            os.path.join(db_dir, 'another_tmp_file.db.tmp'),
            os.path.join(db_dir, 'one_last_temp.db.tmp'),
            os.path.join(db_dir, 'some_temp_file.tmp')]
        self.assertEqual(expected_paths_called, sorted(calls))


class TestHandoffsOnly(unittest.TestCase):
    class FakeRing3Nodes(object):
        _replicas = 3

        # Three nodes, two disks each
        devs = [
            dict(id=0, region=1, zone=1,
                 meta='', weight=500.0, ip='10.0.0.1', port=6201,
                 replication_ip='10.0.0.1', replication_port=6201,
                 device='sdp'),
            dict(id=1, region=1, zone=1,
                 meta='', weight=500.0, ip='10.0.0.1', port=6201,
                 replication_ip='10.0.0.1', replication_port=6201,
                 device='sdq'),

            dict(id=2, region=1, zone=1,
                 meta='', weight=500.0, ip='10.0.0.2', port=6201,
                 replication_ip='10.0.0.2', replication_port=6201,
                 device='sdp'),
            dict(id=3, region=1, zone=1,
                 meta='', weight=500.0, ip='10.0.0.2', port=6201,
                 replication_ip='10.0.0.2', replication_port=6201,
                 device='sdq'),

            dict(id=4, region=1, zone=1,
                 meta='', weight=500.0, ip='10.0.0.3', port=6201,
                 replication_ip='10.0.0.3', replication_port=6201,
                 device='sdp'),
            dict(id=5, region=1, zone=1,
                 meta='', weight=500.0, ip='10.0.0.3', port=6201,
                 replication_ip='10.0.0.3', replication_port=6201,
                 device='sdq'),
        ]

        def __init__(self, *a, **kw):
            pass

        def get_part(self, account, container=None, obj=None):
            return 0

        def get_part_nodes(self, part):
            nodes = []
            for offset in range(self._replicas):
                i = (part + offset) % len(self.devs)
                nodes.append(self.devs[i])
            return nodes

        def get_more_nodes(self, part):
            for offset in range(self._replicas, len(self.devs)):
                i = (part + offset) % len(self.devs)
                yield self.devs[i]

    def _make_fake_db(self, disk, partition, db_hash):
        directories = [
            os.path.join(self.root, disk),
            os.path.join(self.root, disk, 'containers'),
            os.path.join(self.root, disk, 'containers', str(partition)),
            os.path.join(self.root, disk, 'containers', str(partition),
                         db_hash[-3:]),
            os.path.join(self.root, disk, 'containers', str(partition),
                         db_hash[-3:], db_hash)]

        for d in directories:
            try:
                os.mkdir(d)
            except OSError as err:
                if err.errno != errno.EEXIST:
                    raise
        file_path = os.path.join(directories[-1], db_hash + ".db")
        with open(file_path, 'w'):
            pass

    def setUp(self):
        self.root = mkdtemp()
        self.logger = debug_logger()

        # object disks; they're just here to make sure they don't trip us up
        os.mkdir(os.path.join(self.root, 'sdc'))
        os.mkdir(os.path.join(self.root, 'sdc', 'objects'))
        os.mkdir(os.path.join(self.root, 'sdd'))
        os.mkdir(os.path.join(self.root, 'sdd', 'objects'))

        # part 0 belongs on sdp
        self._make_fake_db('sdp', 0, '010101013cf2b7979af9eaa71cb67220')

        # part 1 does not belong on sdp
        self._make_fake_db('sdp', 1, 'abababab2b5368158355e799323b498d')

        # part 1 belongs on sdq
        self._make_fake_db('sdq', 1, '02020202e30f696a3cfa63d434a3c94e')

        # part 2 does not belong on sdq
        self._make_fake_db('sdq', 2, 'bcbcbcbc15d3835053d568c57e2c83b5')

    def tearDown(self):
        rmtree(self.root, ignore_errors=True)

    def test_scary_warnings(self):
        replicator = ConcreteReplicator({
            'handoffs_only': 'yes',
            'devices': self.root,
            'bind_port': 6201,
            'mount_check': 'no',
        }, logger=self.logger)

        with patch.object(db_replicator, 'whataremyips',
                          return_value=['10.0.0.1']), \
                patch.object(replicator, '_replicate_object'), \
                patch.object(replicator, 'ring', self.FakeRing3Nodes()):
            replicator.run_once()

        self.assertEqual(
            self.logger.get_lines_for_level('warning'),
            [('Starting replication pass with handoffs_only and/or '
              'handoffs_delete enabled. These '
              'modes are not intended for normal operation; use '
              'these options with care.'),
             ('Finished replication pass with handoffs_only and/or '
              'handoffs_delete enabled. If these are no longer required, '
              'disable them.')])

    def test_skips_primary_partitions(self):
        replicator = ConcreteReplicator({
            'handoffs_only': 'yes',
            'devices': self.root,
            'bind_port': 6201,
            'mount_check': 'no',
        })

        with patch.object(db_replicator, 'whataremyips',
                          return_value=['10.0.0.1']), \
                patch.object(replicator, '_replicate_object') as mock_repl, \
                patch.object(replicator, 'ring', self.FakeRing3Nodes()):
            replicator.run_once()

        self.assertEqual(sorted(mock_repl.mock_calls), [
            mock.call('1', os.path.join(
                self.root, 'sdp', 'containers', '1', '98d',
                'abababab2b5368158355e799323b498d',
                'abababab2b5368158355e799323b498d.db'), 0),
            mock.call('2', os.path.join(
                self.root, 'sdq', 'containers', '2', '3b5',
                'bcbcbcbc15d3835053d568c57e2c83b5',
                'bcbcbcbc15d3835053d568c57e2c83b5.db'), 1)])

    def test_override_partitions(self):
        replicator = ConcreteReplicator({
            'devices': self.root,
            'bind_port': 6201,
            'mount_check': 'no',
        })

        with patch.object(db_replicator, 'whataremyips',
                          return_value=['10.0.0.1']), \
                patch.object(replicator, '_replicate_object') as mock_repl, \
                patch.object(replicator, 'ring', self.FakeRing3Nodes()):
            replicator.run_once(partitions="0,2")

        self.assertEqual(sorted(mock_repl.mock_calls), [
            mock.call('0', os.path.join(
                self.root, 'sdp', 'containers', '0', '220',
                '010101013cf2b7979af9eaa71cb67220',
                '010101013cf2b7979af9eaa71cb67220.db'), 0),
            mock.call('2', os.path.join(
                self.root, 'sdq', 'containers', '2', '3b5',
                'bcbcbcbc15d3835053d568c57e2c83b5',
                'bcbcbcbc15d3835053d568c57e2c83b5.db'), 1)])

    def test_override_devices(self):
        replicator = ConcreteReplicator({
            'devices': self.root,
            'bind_port': 6201,
            'mount_check': 'no',
        })

        with patch.object(db_replicator, 'whataremyips',
                          return_value=['10.0.0.1']), \
                patch.object(replicator, '_replicate_object') as mock_repl, \
                patch.object(replicator, 'ring', self.FakeRing3Nodes()):
            replicator.run_once(devices="sdp")

        self.assertEqual(sorted(mock_repl.mock_calls), [
            mock.call('0', os.path.join(
                self.root, 'sdp', 'containers', '0', '220',
                '010101013cf2b7979af9eaa71cb67220',
                '010101013cf2b7979af9eaa71cb67220.db'), 0),
            mock.call('1', os.path.join(
                self.root, 'sdp', 'containers', '1', '98d',
                'abababab2b5368158355e799323b498d',
                'abababab2b5368158355e799323b498d.db'), 0)])

    def test_override_devices_and_partitions(self):
        replicator = ConcreteReplicator({
            'devices': self.root,
            'bind_port': 6201,
            'mount_check': 'no',
        })

        with patch.object(db_replicator, 'whataremyips',
                          return_value=['10.0.0.1']), \
                patch.object(replicator, '_replicate_object') as mock_repl, \
                patch.object(replicator, 'ring', self.FakeRing3Nodes()):
            replicator.run_once(partitions="0,2", devices="sdp")

        self.assertEqual(sorted(mock_repl.mock_calls), [
            mock.call('0', os.path.join(
                self.root, 'sdp', 'containers', '0', '220',
                '010101013cf2b7979af9eaa71cb67220',
                '010101013cf2b7979af9eaa71cb67220.db'), 0)])


class TestReplToNode(BaseUnitTestCase):
    def setUp(self):
        super().setUp()
        db_replicator.ring = FakeRing()
        self.delete_db_calls = []
        self.broker = FakeBroker()
        self.logger = debug_logger()
        self.fake_statsd_client = self.logger.logger.statsd_client
        self.replicator = ConcreteReplicator({'per_diff': 10},
                                             logger=self.logger)
        self.fake_node = {'ip': '127.0.0.1', 'device': 'sda1', 'port': 1000}
        self.fake_info = {'id': 'a', 'point': -1, 'max_row': 20, 'hash': 'b',
                          'created_at': 100, 'put_timestamp': 0,
                          'delete_timestamp': 0, 'count': 0,
                          'metadata': json.dumps({
                              'Test': ('Value', self.ts().internal)})}
        self.replicator._rsync_db = mock.Mock(return_value=True)
        self.replicator._usync_db = mock.Mock(return_value=True)
        self.http = ReplHttp('{"id": 3, "point": -1}')
        self.replicator._http_connect = lambda *args: self.http

    def test_repl_to_node_usync_success(self):
        rinfo = {"id": 3, "point": -1, "max_row": 10, "hash": "c"}
        self.http = ReplHttp(json.dumps(rinfo))
        local_sync = self.broker.get_sync()
        self.assertEqual(self.replicator._repl_to_node(
            self.fake_node, self.broker, '0', self.fake_info), True)
        self.replicator._usync_db.assert_has_calls([
            mock.call(max(rinfo['point'], local_sync), self.broker,
                      self.http, rinfo['id'], self.fake_info['id'])
        ])

    def test_repl_to_node_rsync_success(self):
        rinfo = {"id": 3, "point": -1, "max_row": 9, "hash": "c"}
        self.http = ReplHttp(json.dumps(rinfo))
        self.broker.get_sync()

        self.assertEqual(self.replicator._repl_to_node(
            self.fake_node, self.broker, '0', self.fake_info), True)
        self.assertEqual(1, self.fake_statsd_client.counters[
            'remote_merges']
        )

        self.replicator._rsync_db.assert_has_calls([
            mock.call(self.broker, self.fake_node, self.http,
                      self.fake_info['id'],
                      replicate_method='rsync_then_merge',
                      replicate_timeout=(self.fake_info['count'] / 2000),
                      different_region=False)
        ])

    def test_repl_to_node_already_in_sync(self):
        rinfo = {"id": 3, "point": -1, "max_row": 20, "hash": "b"}
        self.http = ReplHttp(json.dumps(rinfo))
        self.broker.get_sync()
        self.assertEqual(self.replicator._repl_to_node(
            self.fake_node, self.broker, '0', self.fake_info), True)
        self.assertEqual(self.replicator._rsync_db.call_count, 0)
        self.assertEqual(self.replicator._usync_db.call_count, 0)
        lines = self.replicator.logger.get_lines_for_level('debug')
        self.assertIn('in sync with 127.0.0.1:1000/sda1, nothing to do,'
                      ' path: %s, db: %s'
                      % (quote(self.broker.path), self.broker.db_file), lines)

    def test_repl_to_node_metadata_update(self):
        now = Timestamp(time.time()).internal
        rmetadata = {"X-Container-Sysmeta-Test": ("XYZ", now)}
        rinfo = {"id": 3, "point": -1, "max_row": 20, "hash": "b",
                 "metadata": json.dumps(rmetadata)}
        self.http = ReplHttp(json.dumps(rinfo))
        self.broker.get_sync()
        self.assertEqual(self.replicator._repl_to_node(
            self.fake_node, self.broker, '0', self.fake_info), True)
        metadata = self.broker.metadata
        lines = self.replicator.logger.get_lines_for_level('debug')
        self.assertIn("X-Container-Sysmeta-Test", metadata)
        self.assertEqual("XYZ", metadata["X-Container-Sysmeta-Test"][0])
        self.assertEqual(now, metadata["X-Container-Sysmeta-Test"][1])
        self.assertIn('in sync with 127.0.0.1:1000/sda1, nothing to do,'
                      ' path: %s, db: %s' % (quote(self.broker.path),
                                             self.broker.db_file), lines)

    def test_repl_to_node_not_found(self):
        self.http = ReplHttp('{"id": 3, "point": -1}', set_status=404)

        self.assertEqual(self.replicator._repl_to_node(
            self.fake_node, self.broker, '0', self.fake_info, False), True)
        self.assertEqual(1, self.fake_statsd_client.counters['rsyncs'])
        self.replicator._rsync_db.assert_has_calls([
            mock.call(self.broker, self.fake_node, self.http,
                      self.fake_info['id'], different_region=False)
        ])

    def test_repl_to_node_drive_not_mounted(self):
        self.http = ReplHttp('{"id": 3, "point": -1}', set_status=507)

        self.assertRaises(DriveNotMounted, self.replicator._repl_to_node,
                          self.fake_node, FakeBroker(), '0', self.fake_info)

    def test_repl_to_node_300_status(self):
        self.http = ReplHttp('{"id": 3, "point": -1}', set_status=300)

        self.assertFalse(self.replicator._repl_to_node(
            self.fake_node, FakeBroker(), '0', self.fake_info))

    def test_repl_to_node_not_response(self):
        self.http = mock.Mock(replicate=mock.Mock(return_value=None))
        self.assertEqual(self.replicator._repl_to_node(
            self.fake_node, FakeBroker(), '0', self.fake_info), False)

    def test_repl_to_node_small_container_always_usync(self):
        # Tests that a small container that is > 50% out of sync will
        # still use usync.
        rinfo = {"id": 3, "point": -1, "hash": "c"}

        # Turn per_diff back to swift's default.
        self.replicator.per_diff = 1000
        for r, l in ((5, 20), (40, 100), (450, 1000), (550, 1500)):
            rinfo['max_row'] = r
            self.fake_info['max_row'] = l
            self.replicator._usync_db = mock.Mock(return_value=True)
            self.http = ReplHttp(json.dumps(rinfo))
            local_sync = self.broker.get_sync()
            self.assertEqual(self.replicator._repl_to_node(
                self.fake_node, self.broker, '0', self.fake_info), True)
            self.replicator._usync_db.assert_has_calls([
                mock.call(max(rinfo['point'], local_sync), self.broker,
                          self.http, rinfo['id'], self.fake_info['id'])
            ])


class ExampleReplicator(db_replicator.Replicator):
    # We need to have a valid server_type
    server_type = 'object'
    brokerclass = ExampleBroker
    datadir = 'fake'
    default_port = 1000


class TestReplicatorSync(BaseUnitTestCase):

    # override in subclass
    backend = ExampleReplicator.brokerclass
    datadir = ExampleReplicator.datadir
    replicator_daemon = ExampleReplicator
    replicator_rpc = db_replicator.ReplicatorRpc

    def setUp(self):
        super().setUp()
        self.root = mkdtemp()
        self.rpc = self.replicator_rpc(
            self.root, self.datadir, self.backend, mount_check=False,
            logger=debug_logger())
        FakeReplConnection = attach_fake_replication_rpc(self.rpc)
        p = mock.patch.object(db_replicator, 'ReplConnection',
                              FakeReplConnection)
        p.start()
        # it turns out this works very well, even if a test leaks a global
        # patch this will restore the original db_replicator.ReplConnection
        self.addCleanup(p.stop)
        self._ring = unit.FakeRing()
        p = mock.patch.object(db_replicator.ring, 'Ring', self._get_ring)
        p.start()
        self.addCleanup(p.stop)
        self.logger = debug_logger()

    def tearDown(self):
        rmtree(self.root)

    def _get_ring(self, *args, **kwargs):
        return self._ring

    def _get_broker(self, account, container=None, node_index=0):
        hash_ = hash_path(account, container)
        part, nodes = self._ring.get_nodes(account, container)
        drive = nodes[node_index]['device']
        db_path = os.path.join(self.root, drive,
                               storage_directory(self.datadir, part, hash_),
                               hash_ + '.db')
        return self.backend(db_path, account=account, container=container)

    def _get_broker_part_node(self, broker):
        part, nodes = self._ring.get_nodes(broker.account, broker.container)
        storage_dir = broker.db_file[len(self.root):].lstrip(os.path.sep)
        broker_device = storage_dir.split(os.path.sep, 1)[0]
        for node in nodes:
            if node['device'] == broker_device:
                return part, node

    def _get_daemon(self, node, conf_updates):
        conf = {
            'devices': self.root,
            'recon_cache_path': self.root,
            'mount_check': 'false',
            'bind_port': node['replication_port'],
        }
        if conf_updates:
            conf.update(conf_updates)
        return self.replicator_daemon(conf, logger=self.logger)

    def _install_fake_rsync_file(self, daemon, captured_calls=None):
        def _rsync_file(src, remote_file, **kwargs):
            src_path = src.db_file
            if captured_calls is not None:
                captured_calls.append((src_path, remote_file, kwargs))
            remote_server, remote_path = remote_file.split('/', 1)
            dest_path = os.path.join(self.root, remote_path)
            copy(src_path, dest_path)
            return True
        daemon._rsync_file = _rsync_file

    def _run_once(self, node, conf_updates=None, daemon=None):
        daemon = daemon or self._get_daemon(node, conf_updates)
        self._install_fake_rsync_file(daemon)
        with mock.patch('swift.common.db_replicator.whataremyips',
                        new=lambda *a, **kw: [node['replication_ip']]), \
                unit.mock_check_drive(isdir=not daemon.mount_check,
                                      ismount=daemon.mount_check):
            daemon.run_once()
        return daemon

    def test_local_ids(self):
        for drive in ('sda', 'sdb', 'sdd'):
            os.makedirs(os.path.join(self.root, drive, self.datadir))
        for node in self._ring.devs:
            daemon = self._run_once(node)
            if node['device'] == 'sdc':
                self.assertEqual(daemon._local_device_ids, {})
            else:
                self.assertEqual(daemon._local_device_ids,
                                 {node['id']: node})

    def test_clean_up_after_deleted_brokers(self):
        broker = self._get_broker('a', 'c', node_index=0)
        part, node = self._get_broker_part_node(broker)
        part = str(part)
        daemon = self._run_once(node)
        # create a super old broker and delete it!
        forever_ago = time.time() - daemon.reclaim_age
        put_timestamp = Timestamp(forever_ago - 2).internal
        delete_timestamp = Timestamp(forever_ago - 1).internal
        broker.initialize(put_timestamp)
        broker.delete_db(delete_timestamp)
        # if we have a container broker make sure it's reported
        if hasattr(broker, 'reported'):
            info = broker.get_info()
            broker.reported(info['put_timestamp'],
                            info['delete_timestamp'],
                            info['object_count'],
                            info['bytes_used'])
        info = broker.get_replication_info()
        self.assertTrue(daemon.report_up_to_date(info))
        # we have a part dir
        part_root = os.path.join(self.root, node['device'], self.datadir)
        parts = os.listdir(part_root)
        self.assertEqual([part], parts)
        # with a single suffix
        suff = os.listdir(os.path.join(part_root, part))
        self.assertEqual(1, len(suff))
        # running replicator will remove the deleted db
        daemon = self._run_once(node, daemon=daemon)
        self.assertEqual(1, daemon.stats['remove'])
        # which also takes out the empty part dir
        parts = os.listdir(part_root)
        self.assertEqual(0, len(parts))

    def test_rsync_then_merge(self):
        # setup current db (and broker)
        broker = self._get_broker('a', 'c', node_index=0)
        part, node = self._get_broker_part_node(broker)
        part = str(part)
        put_timestamp = self.ts().internal
        broker.initialize(put_timestamp)
        put_metadata = {'example-meta': ['bah', put_timestamp]}
        broker.update_metadata(put_metadata)

        # sanity (re-open, and the db keeps the metadata)
        broker = self._get_broker('a', 'c', node_index=0)
        self.assertEqual(put_metadata, broker.metadata)

        # create rsynced db in tmp dir
        obj_hash = hash_path('a', 'c')
        rsynced_db_broker = self.backend(
            os.path.join(self.root, node['device'], 'tmp', obj_hash + '.db'),
            account='a', container='b')
        rsynced_db_broker.initialize(put_timestamp)

        # do rysnc_then_merge
        rpc = db_replicator.ReplicatorRpc(
            self.root, self.datadir, self.backend, False)
        response = rpc.dispatch((node['device'], part, obj_hash),
                                ['rsync_then_merge', obj_hash + '.db', 'arg2'])
        # sanity
        self.assertEqual('204 No Content', response.status)
        self.assertEqual(204, response.status_int)

        # re-open the db
        broker = self._get_broker('a', 'c', node_index=0)
        # keep the metadata in existing db
        self.assertEqual(put_metadata, broker.metadata)

    def test_replicator_sync(self):
        # setup current db (and broker)
        broker = self._get_broker('a', 'c', node_index=0)
        part, node = self._get_broker_part_node(broker)
        put_timestamp = self.ts().internal
        broker.initialize(put_timestamp)
        put_metadata = {'example-meta': ['bah', put_timestamp]}
        sync_local_metadata = {
            "meta1": ["data1", put_timestamp],
            "meta2": ["data2", put_timestamp]}
        broker.update_metadata(put_metadata)

        # sanity (re-open, and the db keeps the metadata)
        broker = self._get_broker('a', 'c', node_index=0)
        self.assertEqual(put_metadata, broker.metadata)

        # do rysnc_then_merge
        rpc = db_replicator.ReplicatorRpc(
            self.root, self.datadir, ExampleBroker, False)
        response = rpc.sync(
            broker, (broker.get_sync('id_') + 1, 12345, 'id_',
                     put_timestamp, put_timestamp, '0',
                     json.dumps(sync_local_metadata)))
        # sanity
        self.assertEqual('200 OK', response.status)
        self.assertEqual(200, response.status_int)

        # re-open the db
        broker = self._get_broker('a', 'c', node_index=0)
        # keep the both metadata in existing db and local db
        expected = put_metadata.copy()
        expected.update(sync_local_metadata)
        self.assertEqual(expected, broker.metadata)


if __name__ == '__main__':
    unittest.main()
