e9638cc6ed
Problem: Lock verschwand nach 5 Minuten weil: 1. JWT-Token nach 15 Min ablief -> Heartbeat schlug still fehl 2. Server gab Lock nach 5 Min ohne Heartbeat frei Fix Client: - Token-Refresh alle 10 Minuten (vor dem 15-Min-Ablauf) - Aktualisiert den Token in der shared API-Instanz - Heartbeat nutzt immer den aktuellen Token Fix Backend: - Lock-Timeout von 5 auf 15 Minuten erhoeht - Genug Puffer fuer Netzwerk-Probleme oder kurze Unterbrechungen Timeline: 0s -> Lock + Heartbeat alle 10s 600s -> Token-Refresh 900s -> Lock wuerde erst jetzt ablaufen (15 Min ohne Heartbeat) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
60 lines
2.3 KiB
Python
60 lines
2.3 KiB
Python
from datetime import datetime, timezone, timedelta
|
|
|
|
from app.extensions import db
|
|
|
|
# Lock expires after 15 minutes without heartbeat
|
|
# Client sends heartbeat every 10 seconds and refreshes JWT every 10 minutes
|
|
LOCK_TIMEOUT_MINUTES = 15
|
|
|
|
|
|
class FileLock(db.Model):
|
|
__tablename__ = 'file_locks'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
file_id = db.Column(db.Integer, db.ForeignKey('files.id'), unique=True, nullable=False, index=True)
|
|
locked_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
|
locked_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
|
heartbeat_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
|
client_info = db.Column(db.String(255), nullable=True) # e.g. "Desktop-Client Windows"
|
|
|
|
file = db.relationship('File', backref=db.backref('lock', uselist=False))
|
|
user = db.relationship('User', backref='file_locks')
|
|
|
|
def is_expired(self):
|
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=LOCK_TIMEOUT_MINUTES)
|
|
return self.heartbeat_at.replace(tzinfo=timezone.utc) < cutoff
|
|
|
|
def to_dict(self):
|
|
return {
|
|
'id': self.id,
|
|
'file_id': self.file_id,
|
|
'locked_by': self.locked_by,
|
|
'locked_by_username': self.user.username if self.user else None,
|
|
'locked_at': self.locked_at.isoformat() if self.locked_at else None,
|
|
'heartbeat_at': self.heartbeat_at.isoformat() if self.heartbeat_at else None,
|
|
'client_info': self.client_info,
|
|
'is_expired': self.is_expired(),
|
|
}
|
|
|
|
@staticmethod
|
|
def cleanup_expired():
|
|
"""Remove all expired locks."""
|
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=LOCK_TIMEOUT_MINUTES)
|
|
expired = FileLock.query.filter(FileLock.heartbeat_at < cutoff).all()
|
|
count = len(expired)
|
|
for lock in expired:
|
|
db.session.delete(lock)
|
|
if count:
|
|
db.session.commit()
|
|
return count
|
|
|
|
@staticmethod
|
|
def get_lock(file_id):
|
|
"""Get active (non-expired) lock for a file, cleaning up expired ones."""
|
|
lock = FileLock.query.filter_by(file_id=file_id).first()
|
|
if lock and lock.is_expired():
|
|
db.session.delete(lock)
|
|
db.session.commit()
|
|
return None
|
|
return lock
|