def test_using_lock_when_already_locked(self) -> None:
        lock_manager = GCSPseudoLockManager()
        lock_manager.lock(self.LOCK_NAME)

        with self.assertRaises(GCSPseudoLockAlreadyExists):
            with lock_manager.using_lock(self.LOCK_NAME, self.CONTENTS):
                pass
    def test_raise_from_using_lock(self) -> None:
        lock_manager = GCSPseudoLockManager()

        with self.assertRaises(ValueError):
            with lock_manager.using_lock(self.LOCK_NAME, self.CONTENTS):
                raise ValueError

        # lock should be unlocked outside of with
        self.assertFalse(lock_manager.is_locked(self.LOCK_NAME))
    def test_unlock_delete_fails_using_lock(self) -> None:
        self.gcs_factory_patcher.stop()
        self.gcs_factory_patcher.start().return_value = _FailingDeleteFs()

        lock_manager = GCSPseudoLockManager(self.PROJECT_ID)

        with self.assertRaises(GCSPseudoLockFailedUnlock):
            with lock_manager.using_lock(self.LOCK_NAME):
                pass
    def test_using_lock(self) -> None:
        lock_manager = GCSPseudoLockManager()
        with lock_manager.using_lock(self.LOCK_NAME, self.CONTENTS):
            self.assertTrue(lock_manager.is_locked(self.LOCK_NAME))

            # Contents appropriately set
            actual_contents = lock_manager.get_lock_contents(self.LOCK_NAME)
            self.assertEqual(self.CONTENTS, actual_contents)

        # lock should be unlocked outside of with
        self.assertFalse(lock_manager.is_locked(self.LOCK_NAME))
Exemplo n.º 5
0
class DirectIngestRegionLockManager:
    """Manages acquiring and releasing the lock for the ingest process that writes
    data to Postgres for a given region's ingest instance.
    """

    def __init__(
        self,
        region_code: str,
        ingest_instance: DirectIngestInstance,
        blocking_locks: List[str],
    ) -> None:
        """
        Args:
            region_code: The region code for the region to lock / unlock ingest for.
            blocking_locks: Any locks that, if present, mean ingest into Postgres
                cannot proceed for this region.
        """
        self.region_code = region_code
        self.ingest_instance = ingest_instance
        self.blocking_locks = blocking_locks
        self.lock_manager = GCSPseudoLockManager()

    def is_locked(self) -> bool:
        """Returns True if the ingest lock is held for the region associated with this
        lock manager.
        """
        return self.lock_manager.is_locked(self._ingest_lock_name_for_instance())

    def can_proceed(self) -> bool:
        """Returns True if ingest can proceed for the region associated with this
        lock manager.
        """
        for lock in self.blocking_locks:
            if self.lock_manager.is_locked(lock):
                return False
        return True

    def acquire_lock(self) -> None:
        self.lock_manager.lock(self._ingest_lock_name_for_instance())

    def release_lock(self) -> None:
        self.lock_manager.unlock(self._ingest_lock_name_for_instance())

    @contextmanager
    def using_region_lock(
        self,
        *,
        expiration_in_seconds: int,
    ) -> Iterator[None]:
        """A context manager for acquiring the lock for a given region. Usage:
        with lock_manager.using_region_lock(expiration_in_seconds=60):
           ... do work requiring the lock
        """
        with self.lock_manager.using_lock(
            self._ingest_lock_name_for_instance(),
            expiration_in_seconds=expiration_in_seconds,
        ):
            yield

    @staticmethod
    def for_state_ingest(
        state_code: StateCode, ingest_instance: DirectIngestInstance
    ) -> "DirectIngestRegionLockManager":
        return DirectIngestRegionLockManager.for_direct_ingest(
            region_code=state_code.value,
            ingest_instance=ingest_instance,
            schema_type=SchemaType.STATE,
        )

    @staticmethod
    def for_direct_ingest(
        region_code: str,
        ingest_instance: DirectIngestInstance,
        schema_type: DirectIngestSchemaType,
    ) -> "DirectIngestRegionLockManager":
        return DirectIngestRegionLockManager(
            region_code=region_code,
            ingest_instance=ingest_instance,
            blocking_locks=[
                postgres_to_bq_lock_name_for_schema(schema_type),
                postgres_to_bq_lock_name_for_schema(SchemaType.OPERATIONS),
            ],
        )

    def _ingest_lock_name_for_instance(self) -> str:
        if StateCode.is_state_code(self.region_code):
            return (
                STATE_GCS_TO_POSTGRES_INGEST_RUNNING_LOCK_PREFIX
                + self.region_code.upper()
                + f"_{self.ingest_instance.name}"
            )
        return (
            JAILS_GCS_TO_POSTGRES_INGEST_RUNNING_LOCK_PREFIX + self.region_code.upper()
        )
class BaseDirectIngestController(Ingestor, Generic[IngestArgsType, ContentsHandleType]):
    """Parses and persists individual-level info from direct ingest partners."""

    def __init__(self, region_name: str, system_level: SystemLevel):
        """Initialize the controller.

        Args:
            region_name: (str) the name of the region to be collected.
        """

        self.region = regions.get_region(region_name, is_direct_ingest=True)
        self.system_level = system_level
        self.cloud_task_manager = DirectIngestCloudTaskManagerImpl()
        self.lock_manager = GCSPseudoLockManager()

    # ============== #
    # JOB SCHEDULING #
    # ============== #
    def kick_scheduler(self, just_finished_job: bool) -> None:
        logging.info("Creating cloud task to schedule next job.")
        self.cloud_task_manager.create_direct_ingest_scheduler_queue_task(
            region=self.region, just_finished_job=just_finished_job, delay_sec=0
        )

    def schedule_next_ingest_job_or_wait_if_necessary(
        self, just_finished_job: bool
    ) -> None:
        """Creates a cloud task to run the next ingest job. Depending on the
        next job's IngestArgs, we either post a task to direct/scheduler/ if
        a wait_time is specified or direct/process_job/ if we can run the next
        job immediately."""
        check_is_region_launched_in_env(self.region)

        if self._schedule_any_pre_ingest_tasks():
            logging.info("Found pre-ingest tasks to schedule - returning.")
            return

        if self.lock_manager.is_locked(self.ingest_process_lock_for_region()):
            logging.info("Direct ingest is already locked on region [%s]", self.region)
            return

        process_job_queue_info = self.cloud_task_manager.get_process_job_queue_info(
            self.region
        )
        if process_job_queue_info.size() and not just_finished_job:
            logging.info(
                "Already running job [%s] - will not schedule another job for "
                "region [%s]",
                process_job_queue_info.task_names[0],
                self.region.region_code,
            )
            return

        next_job_args = self._get_next_job_args()

        if not next_job_args:
            logging.info(
                "No more jobs to run for region [%s] - returning",
                self.region.region_code,
            )
            return

        if process_job_queue_info.is_task_queued(self.region, next_job_args):
            logging.info(
                "Already have task queued for next job [%s] - returning.",
                self._job_tag(next_job_args),
            )
            return

        if self.lock_manager.is_locked(
            postgres_to_bq_lock_name_for_schema(
                schema_type_for_system_level(self.system_level)
            )
        ) or self.lock_manager.is_locked(
            postgres_to_bq_lock_name_for_schema(SchemaType.OPERATIONS)
        ):
            logging.info(
                "Postgres to BigQuery export is running, cannot run ingest - returning"
            )
            return

        # TODO(#3020): Add similar logic between the raw data BQ import and ingest view export tasks
        # TODO(#3162): Delete this wait logic from here once all regions have been transitioned to a SQL
        #  preprocessing model.
        wait_time_sec = self._wait_time_sec_for_next_args(next_job_args)
        logging.info(
            "Found next ingest job to run [%s] with wait time [%s].",
            self._job_tag(next_job_args),
            wait_time_sec,
        )

        if wait_time_sec:
            scheduler_queue_info = self.cloud_task_manager.get_scheduler_queue_info(
                self.region
            )
            if scheduler_queue_info.size() <= 1:
                logging.info(
                    "Creating cloud task to fire timer in [%s] seconds", wait_time_sec
                )
                self.cloud_task_manager.create_direct_ingest_scheduler_queue_task(
                    region=self.region, just_finished_job=False, delay_sec=wait_time_sec
                )
            else:
                logging.info(
                    "[%s] tasks already in the scheduler queue for region "
                    "[%s] - not queueing another task.",
                    str(scheduler_queue_info.size),
                    self.region.region_code,
                )
        else:
            logging.info(
                "Creating cloud task to run job [%s]", self._job_tag(next_job_args)
            )
            self.cloud_task_manager.create_direct_ingest_process_job_task(
                region=self.region, ingest_args=next_job_args
            )
            self._on_job_scheduled(next_job_args)

    def _schedule_any_pre_ingest_tasks(self) -> bool:
        """Schedules any tasks related to SQL preprocessing of new files in preparation for ingest of those files into
        our Postgres database.

        Returns True if any jobs were scheduled or if there were already any pre-ingest jobs scheduled. Returns False if
        there are no remaining ingest jobs to schedule and it is safe to proceed with ingest.
        """
        if self._schedule_raw_data_import_tasks():
            logging.info("Found pre-ingest raw data import tasks to schedule.")
            return True
        # TODO(#3020): We have logic to ensure that we wait 10 min for all files to upload properly before moving on to
        #  ingest. We probably actually need this to happen between raw data import and ingest view export steps - if we
        #  haven't seen all files yet and most recent raw data file came in sometime in the last 10 min, we should wait
        #  to do view exports.
        if self._schedule_ingest_view_export_tasks():
            logging.info("Found pre-ingest view export tasks to schedule.")
            return True
        return False

    def _schedule_raw_data_import_tasks(self) -> bool:
        return False

    def _schedule_ingest_view_export_tasks(self) -> bool:
        return False

    @abc.abstractmethod
    def _get_next_job_args(self) -> Optional[IngestArgsType]:
        """Should be overridden to return args for the next ingest job, or
        None if there is nothing to process."""

    @abc.abstractmethod
    def _on_job_scheduled(self, ingest_args: IngestArgsType) -> None:
        """Called from the scheduler queue when an individual direct ingest job
        is scheduled.
        """

    def _wait_time_sec_for_next_args(self, _: IngestArgsType) -> int:
        """Should be overwritten to return the number of seconds we should
        wait to try to run another job, given the args of the next job we would
        run if we had to run a job right now. This gives controllers the ability
        to back off if we want to attempt to enforce an ingest order for files
        that might all be uploaded around the same time, but in inconsistent
        order.
        """
        return 0

    # =================== #
    # SINGLE JOB RUN CODE #
    # =================== #
    def run_ingest_job_and_kick_scheduler_on_completion(
        self, args: IngestArgsType
    ) -> None:
        check_is_region_launched_in_env(self.region)

        if self.lock_manager.is_locked(
            postgres_to_bq_lock_name_for_schema(
                schema_type_for_system_level(self.system_level)
            )
        ) or self.lock_manager.is_locked(
            postgres_to_bq_lock_name_for_schema(SchemaType.OPERATIONS)
        ):
            raise GCSPseudoLockAlreadyExists(
                "Postgres to BigQuery export is running, can not run ingest"
            )

        with self.lock_manager.using_lock(self.ingest_process_lock_for_region()):
            should_schedule = self._run_ingest_job(args)

        if should_schedule:
            self.kick_scheduler(just_finished_job=True)
            logging.info("Done running task. Returning.")

    def _run_ingest_job(self, args: IngestArgsType) -> bool:
        """
        Runs the full ingest process for this controller - reading and parsing
        raw input data, transforming it to our schema, then writing to the
        database.
        Returns:
            True if we should try to schedule the next job on completion. False,
             otherwise.
        """
        check_is_region_launched_in_env(self.region)

        start_time = datetime.datetime.now()
        logging.info("Starting ingest for ingest run [%s]", self._job_tag(args))

        contents_handle = self._get_contents_handle(args)

        if contents_handle is None:
            logging.warning(
                "Failed to get contents handle for ingest run [%s] - " "returning.",
                self._job_tag(args),
            )
            # If the file no-longer exists, we do want to kick the scheduler
            # again to pick up the next file to run. We expect this to happen
            # occasionally as a race when the scheduler picks up a file before
            # it has been properly moved.
            return True

        if not self._can_proceed_with_ingest_for_contents(args, contents_handle):
            logging.warning(
                "Cannot proceed with contents for ingest run [%s] - returning.",
                self._job_tag(args),
            )
            # If we get here, we've failed to properly split a file picked up
            # by the scheduler. We don't want to schedule a new job after
            # returning here, otherwise we'll get ourselves in a loop where we
            # continually try to schedule this file.
            return False

        logging.info(
            "Successfully read contents for ingest run [%s]", self._job_tag(args)
        )

        if not self._are_contents_empty(args, contents_handle):
            self._parse_and_persist_contents(args, contents_handle)
        else:
            logging.warning(
                "Contents are empty for ingest run [%s] - skipping parse and "
                "persist steps.",
                self._job_tag(args),
            )

        self._do_cleanup(args)

        duration_sec = (datetime.datetime.now() - start_time).total_seconds()
        logging.info(
            "Finished ingest in [%s] sec for ingest run [%s].",
            str(duration_sec),
            self._job_tag(args),
        )

        return True

    @trace.span
    def _parse_and_persist_contents(
        self, args: IngestArgsType, contents_handle: ContentsHandleType
    ) -> None:
        """
        Runs the full ingest process for this controller for files with
        non-empty contents.
        """
        ingest_info = self._parse(args, contents_handle)
        if not ingest_info:
            raise DirectIngestError(
                error_type=DirectIngestErrorType.PARSE_ERROR,
                msg="No IngestInfo after parse.",
            )

        logging.info(
            "Successfully parsed data for ingest run [%s]", self._job_tag(args)
        )

        ingest_info_proto = ingest_utils.convert_ingest_info_to_proto(ingest_info)

        logging.info(
            "Successfully converted ingest_info to proto for ingest " "run [%s]",
            self._job_tag(args),
        )

        ingest_metadata = self._get_ingest_metadata(args)
        persist_success = persistence.write(ingest_info_proto, ingest_metadata)

        if not persist_success:
            raise DirectIngestError(
                error_type=DirectIngestErrorType.PERSISTENCE_ERROR,
                msg="Persist step failed",
            )

        logging.info("Successfully persisted for ingest run [%s]", self._job_tag(args))

    def _get_ingest_metadata(self, args: IngestArgsType) -> IngestMetadata:
        return IngestMetadata(
            self.region.region_code,
            self.region.jurisdiction_id,
            args.ingest_time,
            self.get_enum_overrides(),
            self.system_level,
        )

    def ingest_process_lock_for_region(self) -> str:
        return (
            GCS_TO_POSTGRES_INGEST_RUNNING_LOCK_NAME + self.region.region_code.upper()
        )

    @abc.abstractmethod
    def _job_tag(self, args: IngestArgsType) -> str:
        """Should be overwritten to return a (short) string tag to identify an
        ingest run in logs.
        """

    @abc.abstractmethod
    def _get_contents_handle(
        self, args: IngestArgsType
    ) -> Optional[ContentsHandleType]:
        """Should be overridden by subclasses to return a handle to the contents
        that can return an iterator over the contents and also manages cleanup
        of resources once we are done with the contents.
        Will return None if the contents could not be read (i.e. if they no
        longer exist).
        """

    @abc.abstractmethod
    def _are_contents_empty(
        self, args: IngestArgsType, contents_handle: ContentsHandleType
    ) -> bool:
        """Should be overridden by subclasses to return True if the contents
        for the given args should be considered "empty" and not parsed. For
        example, a CSV might have a single header line but no actual data.
        """

    @abc.abstractmethod
    def _parse(
        self, args: IngestArgsType, contents_handle: ContentsHandleType
    ) -> IngestInfo:
        """Should be overridden by subclasses to parse raw ingested contents
        into an IngestInfo object.
        """

    @abc.abstractmethod
    def _do_cleanup(self, args: IngestArgsType) -> None:
        """Should be overridden by subclasses to do any necessary cleanup in the
        ingested source once contents have been successfully persisted.
        """

    @abc.abstractmethod
    def _can_proceed_with_ingest_for_contents(
        self, args: IngestArgsType, contents_handle: ContentsHandleType
    ) -> bool:
        """Given a pointer to the contents, can the controller continue with