Skip to content

grass.script: Add locking to init #5591

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions python/grass/app/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ class MapsetLockingException(Exception):
pass


def lock_mapset(mapset_path, force_lock_removal, message_callback):
def lock_mapset(
mapset_path, force_lock_removal, message_callback, process_id=None, env=None
):
"""Acquire a lock for a mapset and return name of new lock file

Raises MapsetLockingException when it is not possible to acquire a lock for the
Expand All @@ -186,6 +188,10 @@ def lock_mapset(mapset_path, force_lock_removal, message_callback):
Assumes that the runtime is set up (specifically that GISBASE is in
the environment).
"""
if process_id is None:
process_id = os.getpid()
if not env:
env = os.environ
if not os.path.exists(mapset_path):
raise MapsetLockingException(_("Path '{}' doesn't exist").format(mapset_path))
if not os.access(mapset_path, os.W_OK):
Expand All @@ -200,9 +206,9 @@ def lock_mapset(mapset_path, force_lock_removal, message_callback):
raise MapsetLockingException(error)
# Check for concurrent use
lockfile = os.path.join(mapset_path, ".gislock")
locker_path = os.path.join(os.environ["GISBASE"], "etc", "lock")
locker_path = os.path.join(env["GISBASE"], "etc", "lock")
ret = subprocess.run(
[locker_path, lockfile, "%d" % os.getpid()], check=False
[locker_path, lockfile, f"{process_id}"], check=False
).returncode
msg = None
if ret == 2:
Expand Down Expand Up @@ -232,3 +238,9 @@ def lock_mapset(mapset_path, force_lock_removal, message_callback):
if msg:
raise MapsetLockingException(msg)
return lockfile


def unlock_mapset(mapset_path):
"""Unlock a mapset"""
lockfile = os.path.join(mapset_path, ".gislock")
gs.try_remove(lockfile)
70 changes: 58 additions & 12 deletions python/grass/script/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,16 @@ def setup_runtime_env(gisbase=None, *, env=None):
set_path_to_python_executable(env=env)


def init(path, location=None, mapset=None, *, grass_path=None, env=None):
def init(
path,
location=None,
mapset=None,
*,
grass_path=None,
env=None,
lock=False,
force_unlock=False,
):
"""Initialize system variables to run GRASS modules

This function is for running GRASS GIS without starting it with the
Expand Down Expand Up @@ -282,6 +291,11 @@ def init(path, location=None, mapset=None, *, grass_path=None, env=None):
with gs.setup.init("~/grassdata/nc_spm_08/user1")
# ... use GRASS modules here

A mapset can be locked which will prevent other session from locking it::

with gs.setup.init("~/grassdata/nc_spm_08/user1", lock=True):
# ... use GRASS tools here

:param path: path to GRASS database
:param location: location name
:param mapset: mapset within given location (default: 'PERMANENT')
Expand Down Expand Up @@ -326,13 +340,26 @@ def init(path, location=None, mapset=None, *, grass_path=None, env=None):
env = os.environ
setup_runtime_env(grass_path, env=env)

# TODO: lock the mapset?
env["GIS_LOCK"] = str(os.getpid())
process_id = os.getpid()
env["GIS_LOCK"] = str(process_id)

if lock:
# We have cyclic imports between grass.script and grass.app.
# pylint: disable=import-outside-toplevel
from grass.app.data import lock_mapset

lock_mapset(
mapset_path.path,
force_lock_removal=force_unlock,
process_id=process_id,
message_callback=lambda x: print(x, file=sys.stderr),
env=env,
)

env["GISRC"] = write_gisrc(
mapset_path.directory, mapset_path.location, mapset_path.mapset
)
return SessionHandle(env=env)
return SessionHandle(env=env, locked=lock)


class SessionHandle:
Expand Down Expand Up @@ -382,10 +409,11 @@ class SessionHandle:
# session ends automatically here, global environment was never modifed
"""

def __init__(self, *, env, active=True):
def __init__(self, *, env, active=True, locked=False):
self._env = env
self._active = active
self._start_time = datetime.datetime.now(datetime.timezone.utc)
self._locked = locked

@property
def active(self):
Expand Down Expand Up @@ -425,14 +453,14 @@ def finish(self):
msg = "Attempt to finish an already finished session"
raise ValueError(msg)
self._active = False
finish(env=self._env, start_time=self._start_time)
finish(env=self._env, start_time=self._start_time, unlock=self._locked)


# clean-up functions when terminating a GRASS session
# these fns can only be called within a valid GRASS session


def clean_default_db(*, modified_after=None, env=None):
def clean_default_db(*, modified_after=None, env=None, gis_env=None):
"""Clean (vacuum) the default db if it is SQLite

When *modified_after* is set, database is cleaned only when it was modified
Expand All @@ -446,7 +474,8 @@ def clean_default_db(*, modified_after=None, env=None):
if not conn or conn["driver"] != "sqlite":
return
# check if db exists
gis_env = gs.gisenv(env=env)
if not gis_env:
gis_env = gs.gisenv(env=env)
database = conn["database"]
database = database.replace("$GISDBASE", gis_env["GISDBASE"])
database = database.replace("$LOCATION_NAME", gis_env["LOCATION_NAME"])
Expand Down Expand Up @@ -496,7 +525,7 @@ def clean_temp(env=None):
)


def finish(*, env=None, start_time=None):
def finish(*, env=None, start_time=None, unlock=False):
"""Terminate the GRASS session and clean up

GRASS commands can no longer be used after this function has been
Expand All @@ -513,17 +542,34 @@ def finish(*, env=None, start_time=None):
When *start_time* is set, it might be used to determine cleaning procedures.
Currently, it is used to do SQLite database vacuum only when database was modified
since the session started.

The function does not check whether the mapset is locked or not, but *unlock* can be
provided to unlock the mapset.
"""
if not env:
env = os.environ

clean_default_db(modified_after=start_time, env=env)
import grass.script as gs

gis_env = gs.gisenv(env=env)

clean_default_db(modified_after=start_time, env=env, gis_env=gis_env)
clean_temp(env=env)
# TODO: unlock the mapset?

# unset the GISRC and delete the file
from grass.script import utils as gutils

gutils.try_remove(env["GISRC"])
del env["GISRC"]
# remove gislock env var (not the gislock itself

if unlock:
# We have cyclic imports between grass.script and grass.app.
# pylint: disable=import-outside-toplevel
from grass.app.data import unlock_mapset

mapset_path = Path(
gis_env["GISDBASE"], gis_env["LOCATION_NAME"], gis_env["MAPSET"]
)
unlock_mapset(mapset_path)
# remove gislock env var
del env["GIS_LOCK"]
116 changes: 116 additions & 0 deletions python/grass/script/tests/grass_script_setup_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import multiprocessing
import os
import sys
from functools import partial

import pytest

import grass.script as gs
from grass.app.data import MapsetLockingException

RUNTIME_GISBASE_SHOULD_BE_PRESENT = "Runtime (GISBASE) should be present"
SESSION_FILE_NOT_DELETED = "Session file not deleted"
Expand Down Expand Up @@ -246,3 +248,117 @@ def test_init_environment_isolation(tmp_path):
# We test if the global environment is intact after closing the session.
assert not os.environ.get("GISRC")
assert not os.environ.get("GISBASE")


@pytest.mark.skipif(
sys.platform.startswith("win"), reason="Locking is disabled on Windows"
)
def test_init_lock_global_environment(tmp_path):
"""Check that init function can lock a mapset and respect that lock.

Locking should fail regardless of using the same environment or not.
Here we are using a global environment as if these would be independent processes.
"""
project = tmp_path / "test"
gs.create_project(project)
with gs.setup.init(project, env=os.environ.copy(), lock=True) as top_session:
gs.run_command("g.region", flags="p", env=top_session.env)
# An attempt to lock a locked mapset should fail.
with (
pytest.raises(MapsetLockingException, match=r"Concurrent.*mapset"),
gs.setup.init(project, env=os.environ.copy(), lock=True),
):
pass


def test_init_ignore_lock_global_environment(tmp_path):
"""Check that no locking ignores the present lock"""
project = tmp_path / "test"
gs.create_project(project)
with gs.setup.init(project, env=os.environ.copy(), lock=True) as top_session:
gs.run_command("g.region", flags="p", env=top_session.env)
with gs.setup.init(
project, env=os.environ.copy(), lock=False
) as nested_session:
gs.run_command("g.region", flags="p", env=nested_session.env)
# No locking is the default.
with gs.setup.init(project, env=os.environ.copy()) as nested_session:
gs.run_command("g.region", flags="p", env=nested_session.env)


@pytest.mark.skipif(
sys.platform.startswith("win"), reason="Locking is disabled on Windows"
)
def test_init_lock_nested_environments(tmp_path):
"""Check that init function can lock a mapset using nested environments"""
project = tmp_path / "test"
gs.create_project(project)
with gs.setup.init(project, env=os.environ.copy(), lock=True) as top_session:
gs.run_command("g.region", flags="p", env=top_session.env)
# An attempt to lock a locked mapset should fail.
with (
pytest.raises(MapsetLockingException, match=r"Concurrent.*mapset"),
gs.setup.init(project, env=top_session.env.copy(), lock=True),
):
pass


def test_init_ignore_lock_nested_environments(tmp_path):
"""Check that No locking ignores the present lock using nested environments"""
project = tmp_path / "test"
gs.create_project(project)
with gs.setup.init(project, env=os.environ.copy(), lock=True) as top_session:
gs.run_command("g.region", flags="p", env=top_session.env)
with gs.setup.init(
project, env=top_session.env.copy(), lock=False
) as nested_session:
gs.run_command("g.region", flags="p", env=nested_session.env)
# No locking is the default.
with gs.setup.init(project, env=top_session.env.copy()) as nested_session:
gs.run_command("g.region", flags="p", env=nested_session.env)


def test_init_force_unlock(tmp_path):
"""Force-unlocking should remove an existing lock"""
project = tmp_path / "test"
gs.create_project(project)
with gs.setup.init(project, env=os.environ.copy(), lock=True) as top_session:
gs.run_command("g.region", flags="p", env=top_session.env)
with gs.setup.init(
project, env=os.environ.copy(), lock=True, force_unlock=True
) as nested_session:
gs.run_command("g.region", flags="p", env=nested_session.env)


@pytest.mark.skipif(
sys.platform.startswith("win"), reason="Locking is disabled on Windows"
)
def test_init_lock_fail_with_unlock_false(tmp_path):
"""No force-unlocking should fail if there is an existing lock"""
project = tmp_path / "test"
gs.create_project(project)
with gs.setup.init(project, env=os.environ.copy(), lock=True) as top_session:
gs.run_command("g.region", flags="p", env=top_session.env)
with (
pytest.raises(MapsetLockingException, match=r"Concurrent.*mapset"),
gs.setup.init(
project, env=os.environ.copy(), lock=True, force_unlock=False
),
):
pass


@pytest.mark.skipif(
sys.platform.startswith("win"), reason="Locking is disabled on Windows"
)
def test_init_lock_fail_without_unlock(tmp_path):
"""No force-unlocking is the default and it should fail with an existing lock"""
project = tmp_path / "test"
gs.create_project(project)
with gs.setup.init(project, env=os.environ.copy(), lock=True) as top_session:
gs.run_command("g.region", flags="p", env=top_session.env)
with (
pytest.raises(MapsetLockingException, match=r"Concurrent.*mapset"),
gs.setup.init(project, env=os.environ.copy(), lock=True),
):
pass
Loading