#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You 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.
#

"""Implements a shared object that spans processes.

This object will be instanciated once per VM and methods will be invoked
on it via rpc.
"""
# pytype: skip-file

import atexit
import logging
import multiprocessing.managers
import os
import tempfile
import threading
import time
import traceback
from typing import Any
from typing import Callable
from typing import Generic
from typing import Optional
from typing import TypeVar

import fasteners

from apache_beam.utils import retry

# In some python versions, there is a bug where AutoProxy doesn't handle
# the kwarg 'manager_owned'. We implement our own backup here to make sure
# we avoid this problem. More info here:
# https://stackoverflow.com/questions/46779860/multiprocessing-managers-and-custom-classes
autoproxy = multiprocessing.managers.AutoProxy  # type: ignore[attr-defined]


def patched_autoproxy(
    token,
    serializer,
    manager=None,
    authkey=None,
    exposed=None,
    incref=True,
    manager_owned=True):
  return autoproxy(token, serializer, manager, authkey, exposed, incref)


multiprocessing.managers.AutoProxy = patched_autoproxy  # type: ignore[attr-defined]

T = TypeVar('T')
AUTH_KEY = b'mps'


class _SingletonProxy:
  """Proxies the shared object so we can release it with better errors and no
  risk of dangling references in the multiprocessing manager infrastructure.
  """
  def __init__(self, entry):
    # Guard names so as to not conflict with names of underlying object.
    self._SingletonProxy_entry = entry
    self._SingletonProxy_valid = True

  # Used to make the shared object callable (see _AutoProxyWrapper below)
  def singletonProxy_call__(self, *args, **kwargs):
    if not self._SingletonProxy_valid:
      raise RuntimeError('Entry was released.')
    return self._SingletonProxy_entry.obj.__call__(*args, **kwargs)

  def singletonProxy_release(self):
    assert self._SingletonProxy_valid
    self._SingletonProxy_valid = False

  def singletonProxy_unsafe_hard_delete(self):
    assert self._SingletonProxy_valid
    self._SingletonProxy_entry.unsafe_hard_delete()

  def __getattr__(self, name):
    if not self._SingletonProxy_valid:
      raise RuntimeError('Entry was released.')
    try:
      return getattr(self._SingletonProxy_entry.obj, name)
    except AttributeError as e:
      # Swallow AttributeError exceptions so that they are ignored when
      # calculating public functions. These can occur if __getattr__ is
      # overriden, for example to only support some platforms. This will mean
      # that these functions will be silently unavailable to the
      # MultiProcessShared object, leading to worse errors when someone tries
      # to use them, but it will keep them from breaking the whole object for
      # functions which are unusable anyways.
      logging.info(
          'Attribute %s is unavailable as a public function because '
          'its __getattr__ function raised the following exception '
          '%s',
          name,
          e)
      return None

  def __dir__(self):
    # Needed for multiprocessing.managers's proxying.
    dir = self._SingletonProxy_entry.obj.__dir__()
    dir.append('singletonProxy_call__')
    dir.append('singletonProxy_release')
    dir.append('singletonProxy_unsafe_hard_delete')
    return dir


class _SingletonEntry:
  """Represents a single, refcounted entry in this process."""
  def __init__(
      self, constructor, initialize_eagerly=True, hard_delete_callback=None):
    self.constructor = constructor
    self._hard_delete_callback = hard_delete_callback
    self.refcount = 0
    self.lock = threading.Lock()
    if initialize_eagerly:
      self.obj = constructor()
      self.initialied = True
    else:
      self.initialied = False

  def acquire(self):
    with self.lock:
      if not self.initialied:
        self.obj = self.constructor()
        self.initialied = True
      self.refcount += 1
      return _SingletonProxy(self)

  def release(self, proxy):
    proxy.singletonProxy_release()
    with self.lock:
      self.refcount -= 1
      if self.refcount == 0:
        del self.obj
        self.initialied = False

  def unsafe_hard_delete(self):
    with self.lock:
      if self.initialied:
        del self.obj
        self.initialied = False
      if self._hard_delete_callback:
        self._hard_delete_callback()


class _SingletonManager:
  entries: dict[Any, Any] = {}

  def __init__(self):
    self._hard_delete_callback = None

  def set_hard_delete_callback(self, callback):
    self._hard_delete_callback = callback

  def register_singleton(
      self,
      constructor,
      tag,
      initialize_eagerly=True,
      hard_delete_callback=None):
    assert tag not in self.entries, tag
    self.entries[tag] = _SingletonEntry(
        constructor, initialize_eagerly, hard_delete_callback)

  def has_singleton(self, tag):
    return tag in self.entries

  def acquire_singleton(self, tag):
    return self.entries[tag].acquire()

  def release_singleton(self, tag, obj):
    return self.entries[tag].release(obj)

  def unsafe_hard_delete_singleton(self, tag):
    self.entries[tag].unsafe_hard_delete()


_process_level_singleton_manager = _SingletonManager()

_process_local_lock = threading.Lock()


class _SingletonRegistrar(multiprocessing.managers.BaseManager):
  pass


_SingletonRegistrar.register(
    'acquire_singleton',
    callable=_process_level_singleton_manager.acquire_singleton)
_SingletonRegistrar.register(
    'release_singleton',
    callable=_process_level_singleton_manager.release_singleton)
_SingletonRegistrar.register(
    'unsafe_hard_delete_singleton',
    callable=_process_level_singleton_manager.unsafe_hard_delete_singleton)


# By default, objects registered with BaseManager.register will have only
# public methods available (excluding __call__). If you know the functions
# you would like to expose, you can do so at register time with the `exposed`
# attribute. Since we don't, we will add a wrapper around the returned AutoProxy
# object to handle __call__ function calls and turn them into
# singletonProxy_call__ calls (which is a wrapper around the underlying
# object's __call__ function)
class _AutoProxyWrapper:
  def __init__(self, proxyObject: multiprocessing.managers.BaseProxy):
    self._proxyObject = proxyObject

  def __call__(self, *args, **kwargs):
    return self._proxyObject.singletonProxy_call__(*args, **kwargs)

  def __getattr__(self, name):
    return getattr(self._proxyObject, name)

  def get_auto_proxy_object(self):
    return self._proxyObject

  def unsafe_hard_delete(self):
    self._proxyObject.unsafe_hard_delete()


def _run_server_process(address_file, tag, constructor, authkey, life_line):
  """
    Runs in a separate process.
    Includes a 'Suicide Pact' monitor: If parent dies, I die.
    """
  parent_pid = os.getppid()

  def cleanup_files():
    logging.info("Server process exiting. Deleting files for %s", tag)
    try:
      if os.path.exists(address_file):
        os.remove(address_file)
      if os.path.exists(address_file + ".error"):
        os.remove(address_file + ".error")
    except Exception as e:
      logging.warning('Failed to cleanup files for tag %s: %s', tag, e)

  def handle_unsafe_hard_delete():
    cleanup_files()
    os._exit(0)

  def _monitor_parent():
    """Checks if parent is alive every second."""
    while True:
      try:
        # This will break if parent dies.
        life_line.recv_bytes()
      except (EOFError, OSError, BrokenPipeError):
        logging.warning(
            "Process %s detected Parent %s died. Self-destructing.",
            os.getpid(),
            parent_pid)
        cleanup_files()
        os._exit(0)
      time.sleep(0.5)

  atexit.register(cleanup_files)

  try:
    t = threading.Thread(target=_monitor_parent, daemon=True)

    logging.getLogger().setLevel(logging.INFO)
    multiprocessing.current_process().authkey = authkey

    serving_manager = _SingletonRegistrar(
        address=('localhost', 0), authkey=authkey)
    _process_level_singleton_manager.set_hard_delete_callback(
        handle_unsafe_hard_delete)
    _process_level_singleton_manager.register_singleton(
        constructor,
        tag,
        initialize_eagerly=True,
        hard_delete_callback=handle_unsafe_hard_delete)
    # Start monitoring parent after initialisation is done to avoid
    # potential race conditions.
    t.start()

    server = serving_manager.get_server()
    logging.info(
        'Process %s: Proxy serving %s at %s', os.getpid(), tag, server.address)

    with open(address_file + '.tmp', 'w') as fout:
      fout.write('%s:%d' % server.address)
    os.rename(address_file + '.tmp', address_file)

    server.serve_forever()

  except Exception:
    tb = traceback.format_exc()
    try:
      with open(address_file + ".error.tmp", 'w') as fout:
        fout.write(tb)
      os.rename(address_file + ".error.tmp", address_file + ".error")
    except Exception:
      logging.error("CRITICAL ERROR IN SHARED SERVER:\n%s", tb)
    os._exit(1)


class MultiProcessShared(Generic[T]):
  """MultiProcessShared is used to share a single object across processes.

  For example, one could have the class::

    class MyExpensiveObject(object):
      def __init__(self, args):
        [expensive initialization and memory allocation]

      def method(self, arg):
        ...

  One could share a single instance of this class by wrapping it as::

    shared_ptr = MultiProcessShared(lambda: MyExpensiveObject(...))
    my_expensive_object = shared_ptr.acquire()

  which could then be invoked as::

    my_expensive_object.method(arg)

  This can then be released with::

    shared_ptr.release(my_expensive_object)

  but care should be taken to avoid releasing the object too soon or
  expensive re-initialization may be required, defeating the point of
  using a shared object.


  Args:
    constructor: function that initialises / constructs the object if not
      present in the cache. This function should take no arguments. It should
      return an initialised object, or raise an exception if the object could
      not be initialised / constructed.
    tag: an indentifier to store with the cached object. If multiple
      MultiProcessShared instances are created with the same tag, they will all
      share the same proxied object.
    path: a temporary path in which to create the inter-process lock
    always_proxy: whether to direct all calls through the proxy, rather than
      call the object directly for the process that created it
  """
  def __init__(
      self,
      constructor: Callable[[], T],
      tag: Any,
      *,
      path: str = tempfile.gettempdir(),
      always_proxy: Optional[bool] = None,
      spawn_process: bool = False):
    self._constructor = constructor
    self._tag = tag
    self._path = path
    self._always_proxy = False if always_proxy is None else always_proxy
    self._proxy = None
    self._manager = None
    self._rpc_address = None
    self._cross_process_lock = fasteners.InterProcessLock(
        os.path.join(self._path, self._tag) + '.lock')
    self._spawn_process = spawn_process

  def _get_manager(self):
    if self._manager is None:
      address_file = os.path.join(self._path, self._tag) + ".address"
      while self._manager is None:
        with _process_local_lock:
          with self._cross_process_lock:
            if not os.path.exists(address_file):
              self._create_server(address_file)

            if _process_level_singleton_manager.has_singleton(
                self._tag) and not self._always_proxy:
              self._manager = _process_level_singleton_manager
            else:
              with open(address_file) as fin:
                address = fin.read()
              logging.info('Connecting to remote proxy at %s', address)
              host, port = address.split(':')
              # We need to be able to authenticate with both the manager and
              # the process.
              manager = _SingletonRegistrar(
                  address=(host, int(port)), authkey=AUTH_KEY)
              multiprocessing.current_process().authkey = AUTH_KEY

              retryable_exceptions = (ConnectionError, EOFError)

              @retry.with_exponential_backoff(
                  num_retries=5,
                  initial_delay_secs=0.1,
                  retry_filter=lambda exn: isinstance(
                      exn, retryable_exceptions))
              def connect_manager():
                manager.connect()

              try:
                connect_manager()
                self._manager = manager
              except retryable_exceptions:
                # The server is no longer good, assume it died.
                os.unlink(address_file)

    return self._manager

  def acquire(self):
    # TODO: Allow passing/parameterizing the callable here, in case they are
    # not available at MultiProcessShared construction time (e.g. from side
    # inputs)
    # Caveat: They must always agree, as they will be ignored if the object
    # is already constructed.
    singleton = self._get_manager().acquire_singleton(self._tag)
    # Trigger a sweep of zombie processes.
    # calling active_children() has the side-effect of joining any finished
    # processes, effectively reaping zombies from previous unsafe_hard_deletes.
    if self._spawn_process:
      multiprocessing.active_children()
    return _AutoProxyWrapper(singleton)

  def release(self, obj):
    self._manager.release_singleton(self._tag, obj.get_auto_proxy_object())

  def unsafe_hard_delete(self):
    """Force deletes the underlying object
    
      This function should be used with great care since any other references
      to this object will now be invalid and may lead to strange errors. Only
      call unsafe_hard_delete if either (a) you are sure no other references
      to this object exist, or (b) you are ok with all existing references to
      this object throwing strange errors when derefrenced.
    """
    self._get_manager().unsafe_hard_delete_singleton(self._tag)

  def _create_server(self, address_file):
    if self._spawn_process:
      error_file = address_file + ".error"

      if os.path.exists(error_file):
        try:
          os.remove(error_file)
        except OSError:
          pass

      # Create a pipe to connect with child process
      # used to clean up child process if parent dies
      reader, writer = multiprocessing.Pipe(duplex=False)
      self._life_line = writer

      ctx = multiprocessing.get_context('spawn')
      p = ctx.Process(
          target=_run_server_process,
          args=(address_file, self._tag, self._constructor, AUTH_KEY, reader),
          daemon=False  # Must be False for nested proxies
      )
      p.start()
      logging.info("Parent: Waiting for %s to write address file...", self._tag)

      def cleanup_process():
        if self._life_line:
          self._life_line.close()
        if p.is_alive():
          logging.info(
              "Parent: Terminating server process %s for %s", p.pid, self._tag)
          p.terminate()
          p.join()
        try:
          if os.path.exists(address_file):
            os.remove(address_file)
          if os.path.exists(error_file):
            os.remove(error_file)
        except Exception as e:
          logging.warning(
              'Failed to cleanup files for tag %s in atexit handler: %s',
              self._tag,
              e)

      atexit.register(cleanup_process)

      start_time = time.time()
      last_log = start_time
      while True:
        if os.path.exists(address_file):
          break

        if os.path.exists(error_file):
          with open(error_file, 'r') as f:
            error_msg = f.read()
          try:
            os.remove(error_file)
          except OSError:
            pass

          if p.is_alive(): p.terminate()
          raise RuntimeError(f"Shared Server Process crashed:\n{error_msg}")

        if not p.is_alive():
          exit_code = p.exitcode
          raise RuntimeError(
              "Shared Server Process died unexpectedly"
              f" with exit code {exit_code}")

        if time.time() - last_log > 300:
          logging.warning(
              "Still waiting for %s to initialize... %ss elapsed)",
              self._tag,
              int(time.time() - start_time))
          last_log = time.time()

        time.sleep(0.05)

      logging.info('External process successfully started for %s', self._tag)
    else:
      # We need to be able to authenticate with both the manager
      # and the process.
      self._serving_manager = _SingletonRegistrar(
          address=('localhost', 0), authkey=AUTH_KEY)
      multiprocessing.current_process().authkey = AUTH_KEY
      # Initialize eagerly to avoid acting as the server if there are issues.
      # Note, however, that _create_server itself is called lazily.
      _process_level_singleton_manager.register_singleton(
          self._constructor, self._tag, initialize_eagerly=True)
      self._server = self._serving_manager.get_server()
      logging.info(
          'Starting proxy server at %s for shared %s',
          self._server.address,
          self._tag)
      with open(address_file + '.tmp', 'w') as fout:
        fout.write('%s:%d' % self._server.address)
      os.rename(address_file + '.tmp', address_file)
      t = threading.Thread(target=self._server.serve_forever, daemon=True)
      t.start()
      logging.info('Done starting server')
