#!/usr/bin/env python3
"""
Copyright (c) 2004-Present VMware, Inc. or its affiliates.

This program and the accompanying materials are made available under
the terms of the 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 pty
import os
import subprocess
import re
import multiprocessing
import tempfile
import time
import sys
import socket
from optparse import OptionParser
import traceback
import select
import psycopg2
import io
import shutil

## FIXME: When converting 'INTERVAL' typed value to Python Object, psycopg2 doesn't
## recognize the literal string '@ 0'. It's probably caused by the 'DateStyle' GUC
## setting. It would be good to fix it in future.
def cast_interval(value, cur):
    if value is None:
        return None
    return str(value)
INTERVAL = psycopg2.extensions.new_type((1186,), "INTERVAL", cast_interval)
psycopg2.extensions.register_type(INTERVAL)

def is_digit(n):
    try:
        int(n)
        return True
    except ValueError:
        return  False

class ConnectionInfo(object):
    __instance = None

    def __init__(self):
        self.max_content_id = 0
        self._conn_map = []
        if ConnectionInfo.__instance is not None:
            raise Exception("ConnectionInfo is a singleton.")

        with psycopg2.connect(dbname="postgres") as conn:
            # Don't start transaction automatically.
            conn.set_session(autocommit=True)
            with conn.cursor() as cur:
                cur.execute("SELECT content, hostname, port, role FROM gp_segment_configuration")
                for row in cur:
                    self._conn_map.append(row)

        ConnectionInfo.__instance = self
        for content, _, _, _ in ConnectionInfo.__instance._conn_map:
            if content >= self.max_content_id:
                self.max_content_id = content + 1

    @staticmethod
    def __get_instance():
        if ConnectionInfo.__instance is None:
            return ConnectionInfo()
        return ConnectionInfo.__instance

    @staticmethod
    def get_hostname_port(name, role_name):
        content_id = int(name)
        conn_map = ConnectionInfo.__get_instance()._conn_map
        max_content_id = ConnectionInfo.__get_instance().max_content_id
        real_content_id = content_id % max_content_id if content_id >= 0 else content_id % (-max_content_id)
        for content, host, port, role in conn_map:
            if real_content_id == content and role == role_name:
                return (host, port)
        raise Exception("Cannot find a connection with content_id=%d, role=%c" % (content_id, role_name))


class GlobalShellExecutor(object):
    BASH_PS1 = 'GlobalShellExecutor>'

    class ExecutionError(Exception):
        ""
        pass

    def __init__(self, output_file='', initfile_prefix=''):
        self.output_file = output_file
        self.initfile_prefix = initfile_prefix
        self.v_cnt = 0
        # open pseudo-terminal to interact with subprocess
        self.primary_fd, self.subsidiary_fd = pty.openpty()

        bash_cmd = shutil.which('bash')
        if bash_cmd is None:
            raise GlobalShellExecutor.ExecutionError("cannot find bash command")

        self.sh_proc = subprocess.Popen([bash_cmd, '--noprofile', '--norc', '--noediting', '-i'],
                                        stdin=self.subsidiary_fd,
                                        stdout=self.subsidiary_fd,
                                        stderr=self.subsidiary_fd,
                                        start_new_session=True,
                                        universal_newlines=True)
        self.bash_log_file = open("%s.log" % self.initfile_prefix, "w+")
        self.__run_command("export PS1='%s'" % GlobalShellExecutor.BASH_PS1)
        self.__run_command("export PS2=''")
        self.__run_command("source global_sh_executor.sh")

    def terminate(self, with_error = False):
        if self.sh_proc == None:
            return
        # If write the matchsubs section directly to the output, the generated token id will be compared by gpdiff.pl
        # so here just write all matchsubs section into an auto generated init file when this test case file finished.
        if not with_error and self.initfile_prefix != None and len(self.initfile_prefix) > 1:
            output_init_file = "%s.initfile" % self.initfile_prefix
            cmd = ''' [ ! -z "${MATCHSUBS}" ] && echo "-- start_matchsubs ${NL} ${MATCHSUBS} ${NL}-- end_matchsubs" > %s ''' % output_init_file
            self.exec_global_shell(cmd, False)

        if self.bash_log_file:
            self.bash_log_file.close()
        try:
            self.sh_proc.terminate()
        except OSError as e:
            # Log then ignore the exception if the process doesn't exist.
            print("Failed to terminate bash process: %s" % e)
            pass
        self.sh_proc = None

    def __run_command(self, sh_cmd):
        # Strip extra new lines
        sh_cmd = sh_cmd.rstrip()
        os.write(self.primary_fd, sh_cmd.encode()+b'\n')

        output = ""
        while self.sh_proc.poll() is None:
            # If times out, consider it as an fatal error.
            r, _, e = select.select([self.primary_fd], [], [self.primary_fd], 30)
            if e:
                # Terminate the shell when we get any output from stderr
                o = os.read(self.primary_fd, 10240)
                self.bash_log_file.write(o)
                self.bash_log_file.flush()
                self.terminate(True)
                raise GlobalShellExecutor.ExecutionError("Error happened to the bash process, see %s for details." % self.bash_log_file.name)

            if r:
                o = os.read(self.primary_fd, 10240).decode()
                self.bash_log_file.write(o)
                self.bash_log_file.flush()
                output += o
                if output.endswith(GlobalShellExecutor.BASH_PS1):
                    lines = output.splitlines()
                    return lines[len(sh_cmd.splitlines()):len(lines) - 1]

            if not r and not e:
                self.terminate(True)
                raise GlobalShellExecutor.ExecutionError("Timeout happened to the bash process, see %s for details." % self.bash_log_file.name)

        self.terminate(True)
        raise GlobalShellExecutor.ExecutionError("Bash process has been stopped, see %s for details." % self.bash_log_file.name)

    # execute global shell cmd in bash process, and fetch result without blocking
    def exec_global_shell(self, sh_cmd, is_trip_output_end_blanklines):
        if self.sh_proc == None:
            raise GlobalShellExecutor.ExecutionError("The bash process has been terminated abnormally, see %s for details." % self.bash_log_file.name)

        # get the output of shell command
        output = self.__run_command(sh_cmd)
        if is_trip_output_end_blanklines:
            for i in range(len(output)-1, 0, -1):
                if len(output[i].strip()) == 0:
                    del output[i]
                else:
                    break

        return output

    # execute global shell:
    # 1) set input stream -> $RAW_STR
    # 2) execute shell command from input
    # if error, write error message to err_log_file

    def exec_global_shell_with_orig_str(self, input, sh_cmd, is_trip_output_end_blanklines):
        self.v_cnt = 1 + self.v_cnt
        escape_in = input.replace('\'', "'\\''")
        # send shell cmd and set the temp RAW_STR
        cmd = ''' export RAW_STR%d='%s' && export RAW_STR=$RAW_STR%d && %s ; unset RAW_STR ''' % (
            self.v_cnt, escape_in, self.v_cnt, sh_cmd)
        return self.exec_global_shell(cmd, is_trip_output_end_blanklines)

    # extract shell, sql part from one line with format: @header '': SQL
    # return row: (found the header or not?, the extracted shell, the SQL in the left part)
    def extract_sh_cmd(self, header, input_str):
        start = len(header)
        is_start = False
        end = 0
        is_trip_comma = False
        res_cmd = ""
        res_sql = ""

        input_str = input_str.lstrip()
        if not input_str.startswith(header):
            return (False, None, None)

        for i in range(start, len(input_str)):
            if end == 0 and input_str[i] == '\'':
                if not is_start:
                    # find shell begin position
                    is_start = True
                    start = i+1
                    continue
                cnt = 0
                for j in range(i-1, 0, -1):
                    if input_str[j] == '\\':
                        cnt = 1 + cnt
                    else:
                        break
                if cnt % 2 == 1:
                    continue
                # find shell end position
                res_cmd = input_str[start: i]
                end = i
                continue
            if end != 0:
                # skip space until ':'
                if input_str[i] == ' ':
                    continue
                elif input_str[i] == ':':
                    is_trip_comma = True
                    res_sql = input_str[i+1:]
                    break
        if not is_start or end == 0 or not is_trip_comma:
            raise Exception("Invalid format: %s", input_str)
        #unescape \' to ' and \\ to '
        res_cmd = res_cmd.replace('\\\'', '\'')
        res_cmd = res_cmd.replace('\\\\', '\\')
        return (True, res_cmd, res_sql)

class SQLIsolationExecutor(object):
    def __init__(self, dbname=''):
        self.processes = {}
        # The re.S flag makes the "." in the regex match newlines.
        # When matched against a command in process_command(), all
        # lines in the command are matched and sent as SQL query.
        self.command_pattern = re.compile(r"^(-?\d+|[*])([&\\<\\>URSMq]*?)\:(.*)", re.S)
        if dbname:
            self.dbname = dbname
        else:
            self.dbname = os.environ.get('PGDATABASE')

    # To indicate the session has not been created or terminated.
    class SessionError(Exception):
        def __init__(self, name, mode, msg):
            super(SQLIsolationExecutor.SessionError, self).__init__(msg)
            self.name = name
            self.mode = mode

    class SQLConnection(object):
        def __init__(self, out_file, name, mode, dbname, user = None, passwd = None):
            self.name = name
            self.mode = mode
            self.out_file = out_file
            self.dbname = dbname
            self.user = user
            self.passwd = passwd

            parent_conn, child_conn = multiprocessing.Pipe(True)
            self.p = multiprocessing.Process(target=self.session_process, args=(child_conn,))
            self.pipe = parent_conn
            self.has_open = False
            self.p.start()

            # Close "our" copy of the child's handle, so that if the child dies,
            # recv() on the pipe will fail.
            child_conn.close()

            self.out_file = out_file

        def session_process(self, pipe):
            sp = SQLIsolationExecutor.SQLSessionProcess(self.name,
                self.mode, pipe, self.dbname, user=self.user, passwd=self.passwd)
            sp.do()

        def query(self, command, post_run_cmd = None, global_sh_executor = None):
            print(file=self.out_file)
            self.out_file.flush()
            if len(command.strip()) == 0:
                return
            if self.has_open:
                raise Exception("Cannot query command while waiting for results")

            self.pipe.send((command, False))
            r = self.pipe.recv()
            if r is None:
                raise Exception("Execution failed")
            if re.match(r"^#.*:", r):
                raise SQLIsolationExecutor.SessionError(self.name, self.mode, r)

            if post_run_cmd != None:
                new_out = global_sh_executor.exec_global_shell_with_orig_str(r.rstrip(), post_run_cmd, True)
                for line in new_out:
                    print(line.rstrip(), file=self.out_file)
            else:
                print(r.rstrip(), file=self.out_file)

        def fork(self, command, blocking, global_sh_executor):
            print("  <waiting ...>", file=self.out_file)
            self.pipe.send((command, True))

            if blocking:
                time.sleep(0.5)
                if self.pipe.poll(0):
                    p = self.pipe.recv()
                    raise Exception("Forked command is not blocking; got output: %s" % p.strip())
            self.has_open = True

        def join(self):
            r = None
            print("  <... completed>", file=self.out_file)
            if self.has_open:
                r = self.pipe.recv()
            if r is None:
                raise Exception("Execution failed")
            print(r.rstrip(), file=self.out_file)
            self.has_open = False

        def stop(self):
            self.pipe.send(("", False))
            self.p.join()
            if self.has_open:
                raise Exception("Should not finish test case while waiting for results")

        def quit(self):
            print(" ... <quitting>", file=self.out_file)
            self.stop()

        def terminate(self):
            self.pipe.close()
            self.p.terminate()

    class SQLSessionProcess(object):
        def __init__(self, name, mode, pipe, dbname, user = None, passwd = None):
            """
                Constructor
            """
            self.name = name
            self.mode = mode
            self.pipe = pipe
            self.dbname = dbname
            self.passwd = passwd
            self.user = user
            # If there is an exception thrown when creating session, save it and send
            # it to pipe when we get the first execute_command call.
            self.create_exception = None
            if self.mode == "utility":
                (hostname, port) = self.get_hostname_port(name, 'p')
                self.con = self.connectdb(given_dbname=self.dbname,
                                          given_host=hostname,
                                          given_port=port,
                                          given_opt="-c gp_role=utility",
                                          given_user=user,
                                          given_passwd=passwd)
            elif self.mode == "standby":
                # Connect to standby even when its role is recorded
                # as mirror.  This is useful for scenarios where a
                # test needs to promote a standby without using
                # gpactivatestandby.
                (hostname, port) = self.get_hostname_port(name, 'm')
                self.con = self.connectdb(given_dbname=self.dbname,
                                          given_host=hostname,
                                          given_port=port,
                                          given_user=user,
                                          given_passwd=passwd)
            elif self.mode == "retrieve":
                (hostname, port) = ConnectionInfo.get_hostname_port(name, 'p')
                self.con = self.connectdb(given_dbname=self.dbname,
                                          given_host=hostname,
                                          given_port=port,
                                          given_opt="-c gp_retrieve_conn=true",
                                          given_user=user,
                                          given_passwd=passwd)
            elif self.mode == "mirror":
                # Connect to mirror even when it's role is recorded
                # as mirror.  This is useful for scenarios where a
                # primary is marked down but could actually accept
                # connection. This implies utility connection.
                (hostname, port) = self.get_hostname_port(name, 'm')
                self.con = self.connectdb(given_dbname=self.dbname,
                                          given_host=hostname,
                                          given_port=port,
                                          given_opt="-c gp_role=utility")
            else:
                self.con = self.connectdb(self.dbname)

        def connectdb(self, given_dbname, given_host = None, given_port = None, given_opt = None, given_user = None, given_passwd = None):
            con = None
            retry = 1000
            while retry:
                try:
                    con = psycopg2.connect(host=given_host,
                                           port=given_port,
                                           options=given_opt,
                                           dbname=given_dbname,
                                           user=given_user,
                                           password=given_passwd)
                    break
                except Exception as e:
                    if self.mode == "retrieve" and ("auth token is invalid" in str(e) or "Authentication failure" in str(e) or "does not exist" in str(e)):
                        self.create_exception = e
                        break
                    elif (("the database system is starting up" in str(e) or
                         "the database system is resetting" in str(e) or
                         "the database system is in recovery mode" in str(e)) and
                        retry > 1):
                        retry -= 1
                        time.sleep(0.1)
                    else:
                        raise
            if con is not None:
                # Don't start transaction automatically.
                con.set_session(autocommit=True)
            return con

        def get_hostname_port(self, contentid, role):
            """
                Gets the port number/hostname combination of the
                contentid and role
            """
            con = self.connectdb(self.dbname, given_opt="-c gp_role=utility")
            with con.cursor() as cur:
                cur.execute("SELECT hostname, port FROM gp_segment_configuration WHERE"
                            " content = %s AND role = %s", (contentid, role))
                r = cur.fetchall()
                con.close()
                if len(r) == 0 or len(r[0]) != 2:
                    raise Exception("Invalid content %s" % contentid)
                if r[0][0] == socket.gethostname():
                    return (None, int(r[0][1]))
                return (r[0][0], int(r[0][1]))

        def printout_result(self, description, rows):
            """
            Print out a psycopg2 result set (a Query object, after the query
            has been executed), in a format that imitates the default
            formatting of psql. This isn't a perfect imitation: we left-justify
            all the fields and headers, whereas psql centers the header, and
            right-justifies numeric fields. But this is close enough, to make
            gpdiff.pl recognize the result sets as such.
            """
            widths = []
            result = ""

            # Figure out the widths of each column.
            for f in description:
                widths.append(len(f.name))

            for row in rows:
                colno = 0
                for col in row:
                    if col is None:
                        col = ""
                    widths[colno] = max(widths[colno], len(str(col)))
                    colno = colno + 1

            # Start printing. Header first.
            colno = 0
            for f in description:
                if colno > 0:
                    result += "|"
                result += " " + f.name.ljust(widths[colno]) + " "
                colno = colno + 1
            result += "\n"

            # Then the bar ("----+----")
            colno = 0
            for f in description:
                if colno > 0:
                    result += "+"
                result += "".ljust(widths[colno] + 2, "-")
                colno += 1
            result += "\n"

            # Then the result set itself
            for row in rows:
                colno = 0
                for col in row:
                    if colno > 0:
                        result += "|"
                    if isinstance(col, float):
                        col = format(col, "g")
                    elif isinstance(col, bool):
                        if col:
                            col = 't'
                        else:
                            col = 'f'
                    elif col is None:
                        col = ""
                    result += " " + str(col).ljust(widths[colno]) + " "
                    colno = colno + 1
                result += "\n"

            # Finally, the row count
            if len(rows) == 1:
                result += "(1 row)\n"
            else:
                result += "(" + str(len(rows)) + " rows)\n"

            return result

        def execute_command(self, command):
            """
                Executes a given command
            """
            try:
                with self.con.cursor() as cur:
                    ## FIXME: Currently, psycopg2's cursor doesn't support executing 'COPY TO'
                    ## commands. We use the copy_expert() API to work around.
                    ## Issue: https://github.com/psycopg/psycopg2/issues/444
                    if command.lower().startswith('copy'):
                        cur.copy_expert(command, io.StringIO())
                    else:
                        cur.execute(command)
                    if cur.description is not None:
                        return self.printout_result(cur.description, cur.fetchall())
                    elif cur.statusmessage:
                        return str(cur.statusmessage)
                    return ""
            except psycopg2.Error as e:
                ## Normally, the exception is raised by the server and we can fetch the
                ## error message via e.pgerror. However, there's some situation where the
                ## exception is not raised by the server, e.g., ProgrammingError, we should
                ## print out the error message directly to help debugging.
                if e.pgerror:
                    return str(e.pgerror)
                return str(e)

        def do(self):
            """
                Process loop.
                Ends when the command None is received
            """
            (c, wait) = self.pipe.recv()
            while c:
                if wait:
                    time.sleep(0.1)
                if self.create_exception:
                    # When parent process received this, it should know the connection has not been
                    # created. Thus, the process entry should be cleared.
                    self.pipe.send("#%s%s> %s" % (self.name, self.mode, str(self.create_exception)))
                else:
                    r = self.execute_command(c)
                    self.pipe.send(r)
                r = None

                (c, wait) = self.pipe.recv()


    def get_process(self, out_file, name, mode="", dbname="", user=None, passwd=None):
        """
            Gets or creates the process by the given name
        """
        if len(name) > 0 and not is_digit(name):
            raise Exception("Name should be a number")
        if len(name) > 0 and mode != "utility" and int(name) >= 1024:
            raise Exception("Session name should be smaller than 1024 unless it is utility mode number")

        if not (name, mode) in self.processes:
            if not dbname:
                dbname = self.dbname
            self.processes[(name, mode)] = SQLIsolationExecutor.SQLConnection(out_file, name, mode, dbname, user, passwd)
        return self.processes[(name, mode)]

    def quit_process(self, out_file, name, mode="", dbname=""):
        """
        Quits a process with the given name
        """
        if len(name) > 0 and not is_digit(name):
            raise Exception("Name should be a number")
        if len(name) > 0 and mode != "utility" and int(name) >= 1024:
            raise Exception("Session name should be smaller than 1024 unless it is utility mode number")

        if not (name, mode) in self.processes:
            raise Exception("Sessions not started cannot be quit")

        self.processes[(name, mode)].quit()
        del self.processes[(name, mode)]

    def get_all_primary_contentids(self, dbname):
        """
        Retrieves all primary content IDs (including the coordinator). Intended for
        use by *U queries.
        """
        if not dbname:
            dbname = self.dbname

        with psycopg2.connect(dbname=dbname) as con:
            with con.cursor() as cur:
                cur.execute("SELECT content FROM gp_segment_configuration WHERE role = 'p' order by content")
                result = cur.fetchall()
                if len(result) == 0:
                    raise Exception("Invalid gp_segment_configuration contents")
                return [int(content[0]) for content in result]

    def __preprocess_sql(self, name, pre_run_cmd, sql, global_sh_executor):
        if not pre_run_cmd:
            return sql

        (hostname, port) = ConnectionInfo.get_hostname_port(name, 'p')
        # Inject the current hostname and port to the shell.
        global_sh_executor.exec_global_shell("GP_HOSTNAME=%s" % hostname, True)
        global_sh_executor.exec_global_shell("GP_PORT=%s" % port, True)
        sqls = global_sh_executor.exec_global_shell_with_orig_str(sql, pre_run_cmd, True)
        if (len(sqls) != 1):
            raise Exception("Invalid shell command: %s" % "\n".join(sqls))

        return sqls[0]

    def __get_retrieve_user_token(self, name, global_sh_executor):
        (hostname, port) = ConnectionInfo.get_hostname_port(name, 'p')
        global_sh_executor.exec_global_shell("GP_HOSTNAME=%s" % hostname, True)
        global_sh_executor.exec_global_shell("GP_PORT=%s" % port, True)
        out = global_sh_executor.exec_global_shell("get_retrieve_token", True)
        if (len(out) > 0):
            token = out[0]
        out = global_sh_executor.exec_global_shell("echo ${RETRIEVE_USER}", True)
        if (len(out) > 0):
            user = out[0]
        return (user, token)

    def process_command(self, command, output_file, global_sh_executor):
        """
            Processes the given command.
            The command at this point still includes the isolation behavior
            flags, e.g. which session to use.
        """
        process_name = ""
        sql = command
        flag = ""
        con_mode = ""
        dbname = ""
        retrieve_token = None
        retrieve_user = None
        pre_run_cmd = None
        post_run_cmd = None
        m = self.command_pattern.match(command)
        if m:
            process_name = m.groups()[0]
            flag = m.groups()[1]
            if flag and flag[0] == "U":
                con_mode = "utility"
            elif flag and flag[0] == "S":
                if len(flag) > 1:
                    flag = flag[1:]
                con_mode = "standby"
            elif flag and flag[0] == "R":
                con_mode = "retrieve"
            elif flag and flag[0] == "M":
                con_mode = "mirror"
            sql = m.groups()[2]
            sql = sql.lstrip()
            # If db_name is specified , it should be of the following syntax:
            # 1:@db_name <db_name>: <sql>
            if sql.startswith('@db_name'):
                sql_parts = sql.split(':', 2)
                if not len(sql_parts) == 2:
                    raise Exception("Invalid syntax with dbname, should be of the form 1:@db_name <db_name>: <sql>")
                if not sql_parts[0].startswith('@db_name'):
                    raise Exception("Invalid syntax with dbname, should be of the form 1:@db_name <db_name>: <sql>")
                if not len(sql_parts[0].split()) == 2:
                    raise Exception("Invalid syntax with dbname, should be of the form 1:@db_name <db_name>: <sql>")
                dbname = sql_parts[0].split()[1].strip()
                if not dbname:
                    raise Exception("Invalid syntax with dbname, should be of the form 1:@db_name <db_name>: <sql>")
                sql = sql_parts[1]
            else:
                (found_hd, pre_run_cmd, ex_sql) =  global_sh_executor.extract_sh_cmd('@pre_run', sql)
                if found_hd:
                    sql = ex_sql
                else:
                    (found_hd, post_run_cmd, ex_sql) = global_sh_executor.extract_sh_cmd('@post_run', sql)
                    if found_hd:
                        sql = ex_sql

        if not flag:
            if sql.startswith('!'):
                sql = sql[1:]

                # Check for execution mode. E.g.
                #     !\retcode path/to/executable --option1 --option2 ...
                #
                # At the moment, we only recognize the \retcode mode, which
                # ignores all program output in the diff (it's still printed)
                # and adds the return code.
                mode = None
                if sql.startswith('\\'):
                    mode, sql = sql.split(None, 1)
                    if mode != '\\retcode':
                        raise Exception('Invalid execution mode: {}'.format(mode))

                cmd_output = subprocess.Popen(sql.strip(), stderr=subprocess.STDOUT, stdout=subprocess.PIPE, shell=True)
                stdout, _ = cmd_output.communicate()
                print(file=output_file)
                if mode == '\\retcode':
                    print('-- start_ignore', file=output_file)
                print(stdout.decode(), file=output_file)
                if mode == '\\retcode':
                    print('-- end_ignore', file=output_file)
                    print('(exited with code {})'.format(cmd_output.returncode), file=output_file)
            else:
                sql_new = self.__preprocess_sql(process_name, pre_run_cmd, sql.strip(), global_sh_executor)
                self.get_process(output_file, process_name, con_mode, dbname=dbname).query(sql_new, post_run_cmd, global_sh_executor)
        elif flag == "&":
            self.get_process(output_file, process_name, con_mode, dbname=dbname).fork(sql.strip(), True, global_sh_executor)
        elif flag == ">":
            self.get_process(output_file, process_name, con_mode, dbname=dbname).fork(sql.strip(), False, global_sh_executor)
        elif flag == "<":
            if len(sql) > 0:
                raise Exception("No query should be given on join")
            self.get_process(output_file, process_name, con_mode, dbname=dbname).join()
        elif flag == "q":
            if len(sql) > 0:
                raise Exception("No query should be given on quit")
            self.quit_process(output_file, process_name, con_mode, dbname=dbname)
        elif flag == "U":
            if process_name == '*':
                process_names = [str(content) for content in self.get_all_primary_contentids(dbname)]
            else:
                process_names = [process_name]

            for name in process_names:
                sql_new = self.__preprocess_sql(name, pre_run_cmd, sql.strip(), global_sh_executor)
                self.get_process(output_file, name, con_mode, dbname=dbname).query(sql_new, post_run_cmd, global_sh_executor)
        elif flag == "U&":
            sql_new = self.__preprocess_sql(process_name, pre_run_cmd, sql.strip(), global_sh_executor)
            self.get_process(output_file, process_name, con_mode, dbname=dbname).fork(sql_new, True, global_sh_executor)
        elif flag == "U<":
            if len(sql) > 0:
                raise Exception("No query should be given on join")
            self.get_process(output_file, process_name, con_mode, dbname=dbname).join()
        elif flag == "Uq":
            if len(sql) > 0:
                raise Exception("No query should be given on quit")
            self.quit_process(output_file, process_name, con_mode, dbname=dbname)
        elif flag == "S":
            sql_new = self.__preprocess_sql(process_name, pre_run_cmd, sql.strip(), global_sh_executor)
            self.get_process(output_file, process_name, con_mode, dbname=dbname).query(sql_new, post_run_cmd, global_sh_executor)
        elif flag == "R":
            if process_name == '*':
                process_names = [str(content) for content in self.get_all_primary_contentids(dbname)]
            else:
                process_names = [process_name]

            for name in process_names:
                try:
                    sql_new = self.__preprocess_sql(name, pre_run_cmd, sql.strip(), global_sh_executor)
                    (retrieve_user, retrieve_token) = self.__get_retrieve_user_token(name, global_sh_executor)
                    self.get_process(output_file, name, con_mode, dbname=dbname, user=retrieve_user, passwd=retrieve_token).query(sql_new, post_run_cmd, global_sh_executor)
                except SQLIsolationExecutor.SessionError as e:
                    print(str(e), file=output_file)
                    self.processes[(e.name, e.mode)].terminate()
                    del self.processes[(e.name, e.mode)]
        elif flag == "R&":
            sql_new = self.__preprocess_sql(process_name, pre_run_cmd, sql.strip(), global_sh_executor)
            (retrieve_user, retrieve_token) = self.__get_retrieve_user_token(process_name, global_sh_executor)
            self.get_process(output_file, process_name, con_mode, dbname=dbname, user=retrieve_user, passwd=retrieve_token).fork(sql_new, True, global_sh_executor)
        elif flag == "R<":
            if len(sql) > 0:
                raise Exception("No query should be given on join")
            (retrieve_user, retrieve_token) = self.__get_retrieve_user_token(process_name, global_sh_executor)
            self.get_process(output_file, process_name, con_mode, dbname=dbname, user=retrieve_user, passwd=retrieve_token).join()
        elif flag == "Rq":
            if len(sql) > 0:
                raise Exception("No query should be given on quit")

            if process_name == '*':
                process_names = [str(content) for content in self.get_all_primary_contentids(dbname)]
            else:
                process_names = [process_name]

            for name in process_names:
                try:
                    self.quit_process(output_file, name, con_mode, dbname=dbname)
                except Exception as e:
                    print(str(e), file=output_file)
                    pass
        elif flag == "M":
            self.get_process(output_file, process_name, con_mode, dbname=dbname).query(sql.strip(), post_run_cmd, global_sh_executor)
        elif flag == "Mq":
            if len(sql) > 0:
                raise Exception("No query should be given on quit Mq")
            self.quit_process(output_file, process_name, con_mode, dbname=dbname)
        else:
            raise Exception("Invalid isolation flag")

    def process_isolation_file(self, sql_file, output_file, initfile_prefix):
        """
            Processes the given sql file and writes the output
            to output file
        """
        shell_executor = GlobalShellExecutor(output_file, initfile_prefix)
        try:
            command = ""
            newline = False
            for line in sql_file:
                # this logic replicates the python2 behavior of a trailing comma at the end of print
                # i.e. ''' print >>output_file, line.strip(), '''
                print((" " if command and not newline else "") + line.strip(), end="", file=output_file)
                newline = False
                if line[0] == "!":
                    command_part = line # shell commands can use -- for long options like --include
                elif re.match(r";.*--", line) or re.match(r"^--", line):
                    command_part = line.partition("--")[0] # remove comment from line
                else:
                    command_part = line
                if command_part == "" or command_part == "\n":
                    print(file=output_file)
                    newline = True
                elif re.match(r".*;\s*$", command_part) or re.match(r"^\d+[q\\<]:\s*$", line) or re.match(r"^\*Rq:$", line) or re.match(r"^-?\d+[SUMR][q\\<]:\s*$", line):
                    command += command_part
                    try:
                        self.process_command(command, output_file, shell_executor)
                    except GlobalShellExecutor.ExecutionError as e:
                        # error of the GlobalShellExecutor cannot be recovered
                        raise
                    except Exception as e:
                        print("FAILED: ", e, file=output_file)
                    command = ""
                else:
                    command += command_part

            for process in list(self.processes.values()):
                process.stop()
        except:
            for process in list(self.processes.values()):
                process.terminate()
            shell_executor.terminate()
            raise
        finally:
            for process in list(self.processes.values()):
                process.terminate()
            shell_executor.terminate()

class SQLIsolationTestCase:
    """
        The isolation test case allows a fine grained control of interleaved
        executing transactions. This is mainly used to test isolation behavior.

        [<#>[flag]:] <sql> | ! <shell scripts or command>
        #: either an integer indicating a unique session, or a content-id if
           followed by U (for utility-mode connections) or R (for retrieve-mode
           connection). In 'U' mode or 'R' mode, the
           content-id can alternatively be an asterisk '*' to perform a
           utility-mode/retrieve-mode query on the coordinator and all primary segments.
           If you want to create multiple connections to the same content-id, just
           increase N in: "content-id + {gpdb segment node number} * N",
           e.g. if gpdb cluster segment number is 3, then:
           (1) the coordinator utility connections can be: -1U, -4U, -7U;
           (2) the seg0 connections can be: 0U, 3U, 6U;
           (3) the seg1 connections can be: 1U, 4U, 7U;
           (4) the seg2 connections can be: 2U, 5U, 8U;
        flag:
            &: expect blocking behavior
            >: running in background without blocking
            <: join an existing session
            q: quit the given session without blocking

            U: connect in utility mode to primary contentid from gp_segment_configuration
            U&: expect blocking behavior in utility mode (does not currently support an asterisk target)
            U<: join an existing utility mode session (does not currently support an asterisk target)

            R|R&|R<: similar to 'U' meaning except that the connect is in retrieve mode, here don't
               thinking about retrieve mode authentication, just using the normal authentication directly.

        An example is:

        Execute BEGIN in transaction 1
        Execute BEGIN in transaction 2
        Execute INSERT in transaction 2
        Execute SELECT in transaction 1
        Execute COMMIT in transaction 2
        Execute SELECT in transaction 1

        The isolation tests are specified identical to sql-scripts in normal
        SQLTestCases. However, it is possible to prefix a SQL line with
        an transaction identifier followed by a colon (":").
        The above example would be defined by
        1: BEGIN;
        2: BEGIN;
        2: INSERT INTO a VALUES (1);
        1: SELECT * FROM a;
        2: COMMIT;
        1: SELECT * FROM a;

        Blocking behavior can be tested by forking and joining.
        1: BEGIN;
        2: BEGIN;
        1: DELETE FROM foo WHERE a = 4;
        2&: DELETE FROM foo WHERE a = 4;
        1: COMMIT;
        2<:
        2: COMMIT;

        2& forks the command. It is executed in the background. If the
        command is NOT blocking at this point, it is considered an error.
        2< joins the background command and outputs the result of the
        command execution.

        Session ids should be smaller than 1024.

        2U: Executes a utility command connected to port 40000.

        Connecting to a specific database:
        1. If you specify a db_name metadata in the sql file, connect to that database in all open sessions.
        2. If you want a specific session to be connected to a specific database , specify the sql as follows:

        1:@db_name testdb: <sql>
        2:@db_name test2db: <sql>
        1: <sql>
        2: <sql>
        etc

        Here session 1 will be connected to testdb and session 2 will be connected to test2db. You can specify @db_name only at the beginning of the session. For eg:, following would error out:

        1:@db_name testdb: <sql>
        2:@db_name test2db: <sql>
        1: @db_name testdb: <sql>
        2: <sql>
        etc

        Quitting sessions:
        By default, all opened sessions will be stopped only at the end of the sql file execution. If you want to explicitly quit a session
        in the middle of the test execution, you can specify a flag 'q' with the session identifier. For eg:

        1:@db_name testdb: <sql>
        2:@db_name test2db: <sql>
        1: <sql>
        2: <sql>
        1q:
        2: <sql>
        3: <sql>
        2q:
        3: <sql>
        2: @db_name test: <sql>

        1q:  ---> Will quit the session established with testdb.
        2q:  ---> Will quit the session established with test2db.

        The subsequent 2: @db_name test: <sql> will open a new session with the database test and execute the sql against that session.
        Note: Do not expect blocking behavior from explicit quit statements.
        This implies that a subsequent statement can execute while the relevant
        session is still undergoing exit.

        Shell Execution for SQL or Output:

        @pre_run can be used for executing shell command to change input (i.e. each SQL statement) or get input info;
        @post_run can be used for executing shell command to change output (i.e. the result set printed for each SQL execution)
        or get output info. Just use the env variable ${RAW_STR} to refer to the input/out stream before shell execution,
        and the output of the shell command will be used as the SQL executed or output printed into results file.

        1: @post_run ' TOKEN1=` echo "${RAW_STR}" | awk \'NR==3\' | awk \'{print $1}\'` && export MATCHSUBS="${MATCHSUBS}${NL}m/${TOKEN1}/${NL}s/${TOKEN1}/token_id1/${NL}" && echo "${RAW_STR}" ': SELECT token,hostname,status FROM GP_ENDPOINTS WHERE cursorname='c1';
        2R: @pre_run ' echo "${RAW_STR}" | sed "s#@TOKEN1#${TOKEN1}#" ': RETRIEVE ALL FROM "@TOKEN1";

        These 2 sample is to:
        - Sample 1: set env variable ${TOKEN1} to the cell (row 3, col 1) of the result set, and print the raw result.
          The env var ${MATCHSUBS} is used to store the matchsubs section so that we can store it into initfile when
          this test case file is finished executing.
        - Sample 2: replace "@TOKEN1" by generated token which is fetched in sample1

        There are some helper functions which will be sourced automatically to make above
        cases easier. See global_sh_executor.sh for more information.
        $RETRIEVE_USER is a special environment vars which will be read by python to use them
        as the username for retrieve mode session. `None` will be used if the value has not
        been set when start retrieve mode session. See the get_retrieve_token in global_sh_executor.sh
        for more information about how to get the retrieve session password.

        Catalog Modification:

        Some tests are easier to write if it's possible to modify a system
        catalog across the *entire* cluster. To perform a utility-mode query on
        all segments and the coordinator, you can use *U commands:

        *U: SET allow_system_table_mods = true;
        *U: UPDATE pg_catalog.<table> SET <column> = <value> WHERE <cond>;

        Since the number of query results returned by a *U command depends on
        the developer's cluster configuration, it can be useful to wrap them in
        a start_/end_ignore block. (Unfortunately, this also hides legitimate
        failures; a better long-term solution is needed.)

        Block/join flags are not currently supported with *U.

        Line continuation:
        If a line is not ended by a semicolon ';' which is followed by 0 or more spaces, the line will be combined with next line and
        sent together as a single statement.

        e.g.: Send to the server separately:
        1: SELECT * FROM t1; -> send "SELECT * FROM t1;"
        SELECT * FROM t2; -> send "SELECT * FROM t2;"

        e.g.: Send to the server once:
        1: SELECT * FROM
        t1; SELECT * FROM t2; -> "send SELECT * FROM t1; SELECT * FROM t2;"

        ATTENTION:
        Send multi SQL statements once:
        Multi SQL statements can be sent at once, but there are some known issues. Generally only the last query result will be printed.
        But due to the difficulties of dealing with semicolons insides quotes, we always echo the first SQL command instead of the last
        one if query() returns None. This created some strange issues like:

        CREATE TABLE t1 (a INT); INSERT INTO t1 SELECT generate_series(1,1000);
        CREATE 1000 (Should be INSERT 1000, but here the CREATE is taken due to the limitation)
    """

    def run_sql_file(self, sql_file, out_file = None, out_dir = None, optimizer = None):
        """
        Given a sql file and an ans file, this adds the specified gucs (self.gucs) to the sql file , runs the sql
        against the test case database (self.db_name) and verifies the output with the ans file.
        If an 'init_file' exists in the same location as the sql_file, this will be used
        while doing gpdiff.
        """
        # Add gucs to the test sql and form the actual sql file to be run
        if not out_dir:
            out_dir = self.get_out_dir()

        if not os.path.exists(out_dir):
            TINCSystem.make_dirs(out_dir, ignore_exists_error = True)

        if optimizer is None:
            gucs_sql_file = os.path.join(out_dir, os.path.basename(sql_file))
        else:
            # sql file will be <basename>_opt.sql or <basename>_planner.sql based on optimizer
            gucs_sql_file = os.path.join(out_dir, os.path.basename(sql_file).replace('.sql', '_%s.sql' %self._optimizer_suffix(optimizer)))

        self._add_gucs_to_sql_file(sql_file, gucs_sql_file, optimizer)
        self.test_artifacts.append(gucs_sql_file)


        if not out_file:
            if optimizer is None:
                out_file = os.path.join(self.get_out_dir(), os.path.basename(sql_file).replace('.sql', '.out'))
            else:
                # out file will be *_opt.out or *_planner.out based on optimizer
                out_file = os.path.join(self.get_out_dir(), os.path.basename(sql_file).replace('.sql', '_%s.out' %self._optimizer_suffix(optimizer)))

        self.test_artifacts.append(out_file)
        executor = SQLIsolationExecutor(dbname=self.db_name)
        with open(out_file, "w") as f:
            executor.process_isolation_file(open(sql_file), f, out_file)
            f.flush()

        if out_file[-2:] == '.t':
            out_file = out_file[:-2]

        return out_file

if __name__ == "__main__":

    parser = OptionParser()
    parser.add_option("--dbname", dest="dbname",
                      help="connect to database DBNAME", metavar="DBNAME")
    parser.add_option("--initfile_prefix", dest="initfile_prefix",
                      help="The file path prefix for automatically generated initfile", metavar="INITFILE_PREFIX")
    (options, args) = parser.parse_args()

    # Explicitly set multiprocessing start method to 'fork' (Unix
    # default) to make isolation2 work with python3.8+ on MacOS.
    if sys.version_info >= (3, 8) and sys.platform == "darwin":
        multiprocessing.set_start_method('fork')

    executor = SQLIsolationExecutor(dbname=options.dbname)

    executor.process_isolation_file(sys.stdin, sys.stdout, options.initfile_prefix)
