#!/usr/bin/env python
#
# Public Domain 2014-present MongoDB, Inc.
# Public Domain 2008-2014 WiredTiger, Inc.
#
# This is free and unencumbered software released into the public domain.
#
# Anyone is free to copy, modify, publish, use, compile, sell, or
# distribute this software, either in source code form or as a compiled
# binary, for any purpose, commercial or non-commercial, and by any
# means.
#
# In jurisdictions that recognize copyright laws, the author or authors
# of this software dedicate any and all copyright interest in the
# software to the public domain. We make this dedication for the benefit
# of the public at large and to the detriment of our heirs and
# successors. We intend this dedication to be an overt act of
# relinquishment in perpetuity of all present and future rights to this
# software under copyright law.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.

import wiredtiger, wttest, time
from wiredtiger import stat
from wtscenario import make_scenarios
import test_cursor01, test_cursor02, test_cursor03
import test_checkpoint01, test_checkpoint02
from wtdataset import SimpleDataSet, ComplexDataSet, ComplexLSMDataSet
from helper import confirm_does_not_exist
from suite_random import suite_random

# Cursor caching tests
class test_cursor13_base(wttest.WiredTigerTestCase):
    conn_config = 'statistics=(fast)'
    stat_cursor_cache = 0
    stat_cursor_reopen = 0

    # Returns a list: [cursor_cached, cursor_reopened]
    #
    # We want the statistics for operations triggered from our program. The challenge is that
    # eviction threads may cache history store cursors in the background. We address this by
    # subtracting out operations from the history store file. This is tricky because we can't
    # atomically check the connections stats and the history store stats.  So we look at the
    # history store stats before and after the connection stats and only accept a result where
    # the history store stats haven't changed.
    def caching_stats(self):
        hs_stats_uri = 'statistics:file:WiredTigerHS.wt'
        max_tries = 100
        for i in range(max_tries):
            hs_stats_before = self.session.open_cursor(hs_stats_uri, None, None)
            conn_stats = self.session.open_cursor('statistics:', None, None)
            hs_stats_after = self.session.open_cursor(hs_stats_uri, None, None)

            totals = [ conn_stats [stat.conn.cursor_cache][2],
                         conn_stats [stat.conn.cursor_reopen][2] ]
            hs_before = [ hs_stats_before[stat.dsrc.cursor_cache][2],
                          hs_stats_before[stat.dsrc.cursor_reopen][2] ]
            hs_after = [ hs_stats_after[stat.dsrc.cursor_cache][2],
                         hs_stats_after[stat.dsrc.cursor_reopen][2] ]

            hs_stats_before.close()
            hs_stats_after.close()
            conn_stats.close()

            if hs_before[0] == hs_after[0] and hs_before[1] == hs_after[1]:
                break

            # Fail if we haven't been able to get stable history store stats after too many attempts.
            # Seems impossible, but better to check than to have an accidental infinite loop.
            self.assertNotEqual(i, max_tries - 1)

        return [totals[0] - hs_after[0], totals[1] - hs_after[1]]

    # Returns a list: [cursor_sweep, cursor_sweep_buckets,
    #                  cursor_sweep_examined, cursor_sweep_closed]
    def sweep_stats(self):
        stat_cursor = self.session.open_cursor('statistics:', None, None)
        sweep = stat_cursor[stat.conn.cursor_sweep][2]
        buckets = stat_cursor[stat.conn.cursor_sweep_buckets][2]
        examined = stat_cursor[stat.conn.cursor_sweep_examined][2]
        closed = stat_cursor[stat.conn.cursor_sweep_closed][2]
        stat_cursor.close()
        return [sweep, buckets, examined, closed]

    def assert_cursor_cached(self, expect_change):
        stats = self.caching_stats()
        if expect_change:
            self.assertGreater(stats[0], self.stat_cursor_cache)
            self.stat_cursor_cache = stats[0]
        else:
            self.assertEqual(stats[0], self.stat_cursor_cache)

    def assert_cursor_reopened(self, expect_change):
        stats = self.caching_stats()
        if expect_change:
            self.assertGreater(stats[1], self.stat_cursor_reopen)
            self.stat_cursor_reopen = stats[1]
        else:
            self.assertEqual(stats[1], self.stat_cursor_reopen)

    def cursor_stats_init(self):
        stats = self.caching_stats()
        self.stat_cursor_cache = stats[0]
        self.stat_cursor_reopen = stats[1]

# Override other cursor tests with cursors cached.
class test_cursor13_01(test_cursor01.test_cursor01, test_cursor13_base):
    pass

class test_cursor13_02(test_cursor02.test_cursor02, test_cursor13_base):
    pass

class test_cursor13_03(test_cursor03.test_cursor03, test_cursor13_base):
    pass

class test_cursor13_ckpt01(test_checkpoint01.test_checkpoint,
                           test_cursor13_base):
    pass

class test_cursor13_ckpt02(test_checkpoint01.test_checkpoint_cursor,
                           test_cursor13_base):
    pass

class test_cursor13_ckpt03(test_checkpoint01.test_checkpoint_target,
                           test_cursor13_base):
    pass

class test_cursor13_ckpt04(test_checkpoint01.test_checkpoint_cursor_update,
                           test_cursor13_base):
    pass

class test_cursor13_ckpt05(test_checkpoint01.test_checkpoint_last,
                           test_cursor13_base):
    pass

class test_cursor13_ckpt06(test_checkpoint01.test_checkpoint_empty,
                           test_cursor13_base):
    pass

class test_cursor13_ckpt2(test_checkpoint02.test_checkpoint02,
                          test_cursor13_base):
    pass

class test_cursor13_reopens(test_cursor13_base):
    # The SimpleDataSet uses simple tables, that have no column groups or
    # indices. Thus, these tables will be cached. The more complex data sets
    # are not simple, so are not cached and not included in this test.
    types = [
        ('file', dict(uri='file:cursor13_reopen1', dstype=None)),
        ('table', dict(uri='table:cursor13_reopen2', dstype=None)),
        ('sfile', dict(uri='file:cursor13_reopen3', dstype=SimpleDataSet)),
        ('stable', dict(uri='table:cursor13_reopen4', dstype=SimpleDataSet)),
    ]
    connoptions = [
        ('none', dict(connoption='', conn_caching=None)),
        ('enable', dict(connoption='cache_cursors=true', conn_caching=True)),
        ('disable', dict(connoption='cache_cursors=false', conn_caching=False)),
    ]
    sessoptions = [
        ('none', dict(sessoption='', sess_caching=None)),
        ('enable', dict(sessoption='cache_cursors=true', sess_caching=True)),
        ('disable', dict(sessoption='cache_cursors=false', sess_caching=False)),
    ]
    scenarios = make_scenarios(types, connoptions, sessoptions)

    def conn_config(self):
        return self.connoption + ',statistics=(fast)'

    def session_config(self):
        return self.sessoption

    def basic_populate(self, uri, caching_enabled):
        cursor = self.session.open_cursor(uri)
        cursor['A'] = 'B'
        cursor.close()
        self.assert_cursor_cached(caching_enabled)
        cursor = self.session.open_cursor(uri)
        self.assert_cursor_reopened(caching_enabled)
        cursor['B'] = 'C'
        cursor.close()
        self.assert_cursor_cached(caching_enabled)

    def basic_check(self, cursor):
        count = 0
        for x,y in cursor:
            if count == 0:
                self.assertEqual('A', x)
                self.assertEqual('B', y)
            elif count == 1:
                self.assertEqual('B', x)
                self.assertEqual('C', y)
            count += 1
        self.assertEqual(count, 2)

    def basic_reopen(self, nopens, create, caching_enabled):
        session = self.session
        if create:
            session.create(self.uri, 'key_format=S,value_format=S')
            self.basic_populate(self.uri, caching_enabled)
            # At this point, we've cached one cursor.

        # Reopen/close many times, with multiple cursors
        for opens in range(0, nopens):
            # We expect a cursor to be reopened if we did the
            # create operation above or if this is the second or later
            # time through the loop.
            c = session.open_cursor(self.uri)
            self.assert_cursor_reopened(caching_enabled and (opens != 0 or create))

            # With one cursor for this URI already open, we'll only
            # get a reopened cursor if this is the second or later
            # time through the loop.
            c2 = session.open_cursor(self.uri)
            self.assert_cursor_reopened(caching_enabled and opens != 0)

            self.basic_check(c)
            self.basic_check(c2)
            c.close()
            self.assert_cursor_cached(caching_enabled)
            c2.close()
            self.assert_cursor_cached(caching_enabled)

    def dataset_reopen(self, caching_enabled):
        ds = self.dstype(self, self.uri, 100)
        ds.populate()
        self.assert_cursor_cached(caching_enabled)
        ds.check()
        self.assert_cursor_reopened(caching_enabled)

    # Return if caching was configured at the start of the session
    def is_caching_configured(self):
        if self.sess_caching != None:
            return self.sess_caching
        if self.conn_caching != None:
            return self.conn_caching
        return True   # default

    def test_reopen(self):
        caching = self.is_caching_configured()
        self.cursor_stats_init()
        if self.dstype == None:
            self.basic_reopen(100, True, caching)
        else:
            self.dataset_reopen(caching)

    def test_reconfig(self):
        caching = self.is_caching_configured()
        self.cursor_stats_init()
        self.basic_reopen(10, True, caching)
        self.session.reconfigure('cache_cursors=false')
        self.cursor_stats_init()
        self.basic_reopen(10, False, False)
        self.session.reconfigure('cache_cursors=true')
        self.cursor_stats_init()
        self.basic_reopen(10, False, True)

    # Test we can reopen across a verify.
    def test_verify(self):
        if self.dstype != None:
            self.session.reconfigure('cache_cursors=true')
            ds = self.dstype(self, self.uri, 100)
            ds.populate()
            for loop in range(10):
                # We need an extra cursor open to test all code paths in
                # this loop.  After the verify (the second or more time through
                # the loop), the data handle referred to by both cached
                # cursors will no longer be open.
                #
                # The first cursor open will attempt to reopen the
                # first cached cursor, will see the data handle closed,
                # thus will close that cursor and open normally.
                #
                # The second cursor open (in ds.check()) will attempt the
                # reopen the second cached cursor, see the data handle now
                # open and will succeed the reopen.
                #
                # This test checks that reopens of cursor using a an
                # already reopened data handle will work.
                c = self.session.open_cursor(self.uri)
                ds.check()
                c.close()
                s2 = self.conn.open_session()
                s2.verify(self.uri)
                s2.close()

class test_cursor13_drops(test_cursor13_base):
    def open_and_drop(self, uri, cursor_session, drop_session, nopens, ntrials):
        for i in range(0, ntrials):
            cursor_session.create(uri, 'key_format=S,value_format=S')
            for i in range(0, nopens):
                c = cursor_session.open_cursor(uri)
                c.close()
            # The cursor cache is unaffected by the drop, and nothing
            # in the cache should prevent the drop from occurring.
            drop_session.drop(uri)
            confirm_does_not_exist(self, uri)

    def test_open_and_drop(self):
        session = self.session
        for uri in [ 'file:test_cursor13_drops', 'table:test_cursor13_drops' ]:
            self.open_and_drop(uri, session, session, 0, 5)
            self.open_and_drop(uri, session, session, 1, 5)
            self.open_and_drop(uri, session, session, 3, 5)

            # It should still work with different sessions
            session2 = self.conn.open_session(None)
            self.open_and_drop(uri, session2, session, 0, 5)
            self.open_and_drop(uri, session2, session, 1, 5)
            self.open_and_drop(uri, session2, session, 3, 5)
            session2.close()

    def test_open_index_and_drop(self):
        # We should also be able to detect cached cursors
        # for indices
        session = self.session
        uri = 'table:test_cursor13_drops'
        ds = ComplexDataSet(self, uri, 100)
        ds.create()
        indexname = ds.index_name(0)
        c = session.open_cursor(indexname)
        # The index is really open, so we cannot drop the main table.
        self.assertRaises(wiredtiger.WiredTigerError,
            lambda: session.drop(uri))
        c.close()
        session.drop(uri)
        confirm_does_not_exist(self, uri)

        # Same test for indices, but with cursor held by another session.
        # TODO: try with session that DOES have cache_cursors and another
        # that does not.
        session2 = self.conn.open_session(None)
        ds = ComplexDataSet(self, uri, 100)
        ds.create()
        indexname = ds.index_name(0)
        c = session2.open_cursor(indexname)
        self.assertRaises(wiredtiger.WiredTigerError,
            lambda: session.drop(uri))
        c.close()
        session.drop(uri)
        confirm_does_not_exist(self, uri)
        session2.close()

    def test_cursor_drops(self):
        session = self.session
        uri = 'table:test_cursor13_drops'
        idxuri = 'index:test_cursor13_drops:index1'
        config = 'key_format=S,value_format=S,columns=(k,v1)'

        for i in range(0, 2):
            session.create(uri, config)
            session.drop(uri)

        for i in range(0, 2):
            session.create(uri, config)
            cursor = session.open_cursor(uri, None)
            cursor['A'] = 'B'
            self.assertRaises(wiredtiger.WiredTigerError,
                lambda: session.drop(uri))
            cursor.close()
            session.drop(uri)

        for i in range(0, 2):
            session.create(uri, config)
            session.create(idxuri, 'columns=(v1)')
            cursor = session.open_cursor(uri, None)
            cursor['A'] = 'B'
            self.assertRaises(wiredtiger.WiredTigerError,
                lambda: session.drop(uri))
            cursor.close()
            session.drop(uri)

        for i in range(0, 2):
            session.create(uri, config)
            session.create(idxuri, 'columns=(v1)')
            cursor = session.open_cursor(uri, None)
            cursor['A'] = 'B'
            cursor.close()
            cursor = session.open_cursor(idxuri, None)
            self.assertRaises(wiredtiger.WiredTigerError,
                lambda: session.drop(uri))
            cursor.close()
            session.drop(uri)

# Shared base class for some bigger tests.
class test_cursor13_big_base(test_cursor13_base):
    deep = 3
    nuris = 100
    opencount = 0
    closecount = 0
    uri = None   # set by child classes

    def uriname(self, i):
        return self.uri + '.' + str(i)

    # Create a large number (self.nuris) of uris, and for each one,
    # create some number (self.deep) of cached cursors.
    def create_uri_map(self, baseuri):
        uri_map = {}
        for i in range(0, self.nuris):
            uri = self.uriname(i)
            cursors = []
            self.session.create(uri, None)
            for j in range(0, self.deep):
                cursors.append(self.session.open_cursor(uri, None))
            for c in cursors:
                c.close()
            # Each map entry has a list of the open cursors.
            # Since we just closed them, we start with none
            uri_map[uri] = []

        return uri_map

    def close_uris(self, uri_map, range_arg):
        closed = 0
        for i in range_arg:
            cursors = uri_map[self.uriname(i)]
            while len(cursors) > 0:
                cursors.pop().close()
                self.closecount += 1
                closed += 1
        return closed

    def open_or_close(self, uri_map, rand, low, high):
        uri = self.uriname(rand.rand_range(low, high))
        cursors = uri_map[uri]
        ncursors = len(cursors)

        # Keep the range of open cursors between 0 and [deep],
        # with some random fluctuation
        if ncursors == 0:
            do_open = True
        elif ncursors == self.deep:
            do_open = False
        else:
            do_open = (rand.rand_range(0, 2) == 0)

        if do_open:
            cursors.append(self.session.open_cursor(uri, None))
            self.opencount += 1
        else:
            i = rand.rand_range(0, ncursors)
            cursors.pop(i).close()
            self.closecount += 1

class test_cursor13_big(test_cursor13_big_base):
    scenarios = make_scenarios([
        ('file', dict(uri='file:cursor13_sweep_a')),
        ('table', dict(uri='table:cursor13_sweep_b'))
    ])

    nopens = 500000

    def test_cursor_big(self):
        rand = suite_random()
        uri_map = self.create_uri_map(self.uri)
        self.cursor_stats_init()
        begin_stats = self.caching_stats()
        #self.tty('stats before = ' + str(begin_stats))

        # At this point, we'll randomly open/close lots of cursors, keeping
        # track of how many of each. As long as we don't have more than [deep]
        # cursors open for each uri, we should always be taking then from
        # the set of cached cursors.
        while self.opencount < self.nopens:
            self.open_or_close(uri_map, rand, 0, self.nuris)

        end_stats = self.caching_stats()

        #self.tty('opens = ' + str(self.opencount) + \
        #         ', closes = ' + str(self.closecount))
        #self.tty('stats after = ' + str(end_stats))

        self.assertEquals(end_stats[0] - begin_stats[0], self.closecount)
        self.assertEquals(end_stats[1] - begin_stats[1], self.opencount)

class test_cursor13_sweep(test_cursor13_big_base):
    # Set dhandle sweep configuration so that dhandles should be closed within
    # two seconds of all the cursors for the dhandle being closed (cached).
    conn_config = 'statistics=(fast),' + \
                  'file_manager=(close_scan_interval=1,close_idle_time=1,' + \
                  'close_handle_minimum=0)'
    uri = 'table:cursor13_sweep_b'
    opens_per_round = 100000
    rounds = 5

    @wttest.longtest('cursor sweep tests require wait times')
    def test_cursor_sweep(self):
        rand = suite_random()

        uri_map = self.create_uri_map(self.uri)
        self.cursor_stats_init()
        begin_stats = self.caching_stats()
        begin_sweep_stats = self.sweep_stats()
        #self.tty('stats before = ' + str(begin_stats))
        #self.tty('sweep stats before = ' + str(begin_sweep_stats))
        potential_dead = 0

        for round_cnt in range(0, self.rounds):
            if round_cnt % 2 == 1:
                # Close cursors in half of the range, and don't
                # use them during this round, so they will be
                # closed by sweep.
                half = self.nuris // 2
                potential_dead += self.close_uris(uri_map, list(range(0, half)))
                bottom_range = half
                # Let the dhandle sweep run and find the closed cursors.
                time.sleep(3.0)
            else:
                bottom_range = 0

            # The session cursor sweep runs at most once a second and
            # traverses a fraction of the cached cursors.  We'll run for
            # ten seconds with pauses to make sure we see sweep activity.
            pause_point = self.opens_per_round // 100
            if pause_point == 0:
                pause_point = 1
            pause_duration = 0.1

            i = 0
            while self.opencount < (1 + round_cnt) * self.opens_per_round:
                i += 1
                if i % pause_point == 0:
                    time.sleep(pause_duration)   # over time, let sweep run
                self.open_or_close(uri_map, rand, bottom_range, self.nuris)

        end_stats = self.caching_stats()
        end_sweep_stats = self.sweep_stats()

        #self.tty('opens = ' + str(self.opencount) + \
        #         ', closes = ' + str(self.closecount))
        #self.tty('stats after = ' + str(end_stats))
        #self.tty('sweep stats after = ' + str(end_sweep_stats))
        self.assertEquals(end_stats[0] - begin_stats[0], self.closecount)
        swept = end_sweep_stats[3] - begin_sweep_stats[3]

        # Although this is subject to tuning parameters, we know that
        # in an active session, we'll sweep through minimum of 1% of
        # the cached cursors per second.  We've set this test to run
        # 5 rounds. In 2 of the 5 rounds (sandwiched between the others),
        # some of the uris are allowed to close. So during the 'closing rounds'
        # we'll sweep a minimum of 20% of the uri space, and in the other
        # rounds we'll be referencing the closed uris again.

        # We'll pass the test if we see at least 20% of the 'potentially
        # dead' cursors swept.  There may be more, since the 1% per second
        # is a minimum.
        min_swept = 2 * potential_dead // 10
        self.assertGreaterEqual(swept, min_swept)

        # No strict equality test for the reopen stats. When we've swept
        # some closed cursors, we'll have fewer reopens. It's different
        # by approximately the number of swept cursors, but it's less
        # predictable.
        self.assertGreater(end_stats[1] - begin_stats[1], 0)

class test_cursor13_dup(test_cursor13_base):
    def test_dup(self):
        self.cursor_stats_init()
        uri = 'table:test_cursor13_dup'
        self.session.create(uri, 'key_format=S,value_format=S')
        cursor = self.session.open_cursor(uri)
        cursor['A'] = 'B'
        cursor.close()

        # Get a cursor and position it.
        # An unpositioned cursor cannot be duplicated.
        c1 = self.session.open_cursor(uri, None)
        c1.next()

        for notused in range(0, 100):
            c2 = self.session.open_cursor(None, c1, None)
            c2.close()
        stats = self.caching_stats()
        self.assertGreaterEqual(stats[0], 100)  # cursor_cached > 100
        self.assertGreaterEqual(stats[1], 100)  # cursor_reopened > 100
