Skip to content
Open
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
58 changes: 56 additions & 2 deletions django/applications/catmaid/control/skeleton.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, Http404, \
JsonResponse, StreamingHttpResponse
from django.shortcuts import get_object_or_404
from django.db import connection
from django.db import connection, transaction
from django.db.models import Q
from django.views.decorators.cache import never_cache
from django.utils.decorators import method_decorator
Expand All @@ -26,7 +26,9 @@
from rest_framework.response import Response
from rest_framework.views import APIView

from catmaid.history import add_log_entry
from catmaid import locks
from catmaid.history import add_log_entry, Transaction, \
find_latest_deleted_skeleton_transaction, undelete_neuron
from catmaid.control import tracing
from catmaid.models import (Project, UserRole, Class, ClassInstance, Review,
ClassInstanceClassInstance, Relation, Sampler, Treenode,
Expand Down Expand Up @@ -5152,6 +5154,58 @@ def post(self, request:Request, project_id, skeleton_id) -> JsonResponse:
})


RESTORABLE_SKELETON_DELETE_LABEL = 'skeletons.remove'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally I think it would be better if this is defined in one place and reused both here and in urls.py so that this label is kept in sync between the two.
If there is no good candidate place for that then feel free to leave it.



@api_view(['POST'])
@requires_user_role(UserRole.Annotate)
def restore_historic_skeleton(request:HttpRequest, project_id, skeleton_id):
"""Restore the latest deleted historic version of a single skeleton."""
project_id = int(project_id)
skeleton_id = int(skeleton_id)

with transaction.atomic():
cursor = connection.cursor()
cursor.execute("""
SELECT pg_advisory_xact_lock(%(lock_id)s::bigint)
""", {
'lock_id': locks.skeleton_restore_lock_id(skeleton_id),
})
cursor.execute("SET LOCAL catmaid.user_id=%(user_id)s", {
'user_id': request.user.id,
})

if ClassInstance.objects.filter(pk=skeleton_id).exists():
raise ValueError(f"An object with ID {skeleton_id} already exists")

restore_info = find_latest_deleted_skeleton_transaction(
project_id, skeleton_id)
if not restore_info:
raise ValueError(
f"No single-skeleton deleted historic skeleton found for "
f"skeleton {skeleton_id}")

source_label = restore_info['label']
if source_label != RESTORABLE_SKELETON_DELETE_LABEL:
raise ValueError(
f"Latest historic transaction for skeleton {skeleton_id} has "
f"missing or unsupported label {source_label}; expected "
f"{RESTORABLE_SKELETON_DELETE_LABEL}")

tx = Transaction(restore_info['transaction_id'],
restore_info['execution_time'])

undelete_neuron(project_id, tx, user_id=request.user.id)

return JsonResponse({
'skeleton_id': skeleton_id,
'transaction_id': restore_info['transaction_id'],
'execution_time': restore_info['execution_time'],
'source_label': source_label,
'success': f"Restored skeleton {skeleton_id} from history.",
})


@api_view(['POST'])
@requires_user_role(UserRole.Annotate)
def delete_skeleton(request:HttpRequest, project_id, skeleton_id):
Expand Down
4 changes: 4 additions & 0 deletions django/applications/catmaid/control/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@ def get(self):
ORDER BY t.edition_time DESC
LIMIT 1;
"""),
'skeletons.remove': QueryRef(location_queries, "neurons.remove"),
'skeletons.restore': QueryRef(location_queries, "nodes.update_location"),
'textlabels.create': HistoryQuery("""
SELECT t.location_x, t.location_y, t.location_z
FROM textlabel{history} t
Expand Down Expand Up @@ -537,6 +539,8 @@ def get(self):
WHERE so.{txid} = %s AND t.skeleton_id = so.skeleton_id
ORDER BY t.edition_time DESC
"""),
'skeletons.remove': QueryRef(skeleton_queries, "neurons.remove"),
'skeletons.restore': QueryRef(skeleton_queries, "nodes.update_location"),
'textlabels.create': HistoryQuery("""
SELECT t.skeleton_id
FROM textlabel{history} t
Expand Down
76 changes: 76 additions & 0 deletions django/applications/catmaid/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,82 @@ def __str__(self):
return "TX {} @ {}".format(self.id, self.time)


def find_latest_deleted_skeleton_transaction(project_id, skeleton_id):
"""Find the newest transaction that removed the passed in skeleton.

Only single-skeleton delete candidates are returned. Callers still have to
decide whether the transaction label is safe for their restore use case.
"""
cursor = connection.cursor()
cursor.execute("""
WITH skeleton_class AS (
SELECT id
FROM class
WHERE project_id = %(project_id)s
AND class_name = 'skeleton'
),
candidates AS (
SELECT ci.exec_transaction_id AS transaction_id,
upper(ci.sys_period) AS execution_time
FROM class_instance__history ci
JOIN skeleton_class sc
ON sc.id = ci.class_id
WHERE ci.project_id = %(project_id)s
AND ci.id = %(skeleton_id)s
AND ci.sys_period IS NOT NULL
AND NOT isempty(ci.sys_period)
AND NOT upper_inf(ci.sys_period)
GROUP BY ci.exec_transaction_id, upper(ci.sys_period)
),
latest AS (
SELECT transaction_id, execution_time
FROM candidates
ORDER BY execution_time DESC
LIMIT 1
),
affected_skeleton AS (
SELECT ci.id AS skeleton_id
FROM class_instance__history ci
JOIN skeleton_class sc
ON sc.id = ci.class_id
JOIN latest
ON latest.transaction_id = ci.exec_transaction_id
WHERE ci.project_id = %(project_id)s
AND ci.sys_period IS NOT NULL
AND NOT isempty(ci.sys_period)
AND NOT upper_inf(ci.sys_period)
AND upper(ci.sys_period) >= latest.execution_time
),
affected_summary AS (
SELECT COUNT(DISTINCT skeleton_id) AS skeleton_count,
BOOL_OR(skeleton_id = %(skeleton_id)s) AS includes_requested
FROM affected_skeleton
)
SELECT latest.transaction_id,
latest.execution_time::text,
cti.label
FROM latest
JOIN affected_summary affected
ON affected.skeleton_count = 1
AND affected.includes_requested
LEFT JOIN catmaid_transaction_info cti
ON cti.transaction_id = latest.transaction_id
AND cti.execution_time = latest.execution_time
""", {
'project_id': project_id,
'skeleton_id': skeleton_id,
})
result = cursor.fetchone()
if not result:
return None

return {
'transaction_id': result[0],
'execution_time': result[1],
'label': result[2],
}


def get_historic_row_count_affected_by_tx(tx):
"""Counts how many historic rows reference the passed in transaction.
Returned is a list of tuples (table_name, count).
Expand Down
16 changes: 16 additions & 0 deletions django/applications/catmaid/locks.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import hashlib


# The base lock is formed from the multiplication of all characters of "catmaid"
# as ASCII: 99 * 97 * 116 * 109 * 97 * 105 * 100.
base_lock_id = 123666608142000
Expand All @@ -6,3 +9,16 @@
spatial_update_event_lock = base_lock_id + 1
# Postgres advisory lock ID to update history update even handling
history_update_event_lock = base_lock_id + 2
# Postgres advisory lock namespace for historic skeleton restores
skeleton_restore_lock_namespace = base_lock_id + 3


def skeleton_restore_lock_id(skeleton_id):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll admit that I've not fully dug into the exact uses of these pg locks in catmaid, but I think there's a exceedingly rare problem that while is so unlikely it will pretty much never occur, I'd rather we explicitly avoid it. Technically our generated unsigned_lock_id could be the spatial update event lock or the history update event lock and it also makes it a bit hard in the future to add locks since the ID space is variable.

Looking at a different signature for the pg_advisory_xact_lock(key1 integer, key2 integer) we can use two keys which could possibly work here and seems intended for this kind of case. However the base_lock_id is too large in this case as key1. And we'd have to roll over on key2 or hash into 32 bit. But I think it might still work for this use case

"""Return a stable signed 64-bit advisory lock ID for a skeleton restore."""
lock_key = f'{skeleton_restore_lock_namespace}:{int(skeleton_id)}'.encode(
'ascii')
unsigned_lock_id = int.from_bytes(
hashlib.blake2b(lock_key, digest_size=8).digest(), 'big')
if unsigned_lock_id >= 2 ** 63:
return unsigned_lock_id - 2 ** 64
return unsigned_lock_id
Loading