Add instant ACK for async cash payment cache jobs

This commit is contained in:
A R R R Associates
2026-09-04 00:19:59 +05:30
parent 471205053a
commit 5d13373e36
3 changed files with 158 additions and 70 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.22.9" ERP_LOCAL_AGENT_VERSION = "1.22.10"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -1,2 +1,2 @@
__version__ = "1.22.9" __version__ = "1.22.10"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -19,6 +19,7 @@ _CASH_CACHE_BUSY = threading.Event()
_CASH_CACHE_THREADS: dict[str, threading.Thread] = {} _CASH_CACHE_THREADS: dict[str, threading.Thread] = {}
_CASH_CACHE_THREADS_LOCK = threading.Lock() _CASH_CACHE_THREADS_LOCK = threading.Lock()
_CASH_CACHE_CANCEL_REQUESTS: set[str] = set() _CASH_CACHE_CANCEL_REQUESTS: set[str] = set()
_CASH_CACHE_STARTING: dict[str, dict[str, Any]] = {}
def is_tally_cache_busy() -> bool: def is_tally_cache_busy() -> bool:
return _CASH_CACHE_BUSY.is_set() return _CASH_CACHE_BUSY.is_set()
@@ -751,35 +752,66 @@ class AgentCommandProcessor:
raise ValueError("Cash payment cache dates must be valid YYYY-MM-DD dates.") from exc raise ValueError("Cash payment cache dates must be valid YYYY-MM-DD dates.") from exc
if end_day < start_day: if end_day < start_day:
raise ValueError("Cash payment cache To date cannot be before From date.") raise ValueError("Cash payment cache To date cannot be before From date.")
if not self.store.exists(client_id):
raise ValueError("Accounting local storage is not initialized for this client.")
self._ensure_cash_cache_schema(client_id)
# If a previous identical job was interrupted/paused, resume it instead of # Instant acknowledgement: do not open SQLite and do not contact TallyPrime
# discarding already cached days. Completed jobs start a fresh refresh. # on the command thread. The background worker performs all potentially
with self.store.connect(client_id) as db: # slow local-storage/mapping/schema work after this method returns.
prior = db.execute( total_days = (end_day - start_day).days + 1
"""SELECT * FROM cash_payment_cache_jobs job_id = str(uuid.uuid4())
WHERE client_id=? AND tally_guid=? AND date_from=? AND date_to=? pause_seconds = max(2.0, min(float(payload.get("tally_pause_seconds") or 3.0), 15.0))
ORDER BY started_at_utc DESC LIMIT 1""", now = datetime.now(timezone.utc).isoformat()
(client_id, tally_guid, date_from, date_to), starting = {
).fetchone() "job_id": job_id,
prior = dict(prior) if prior else None "client_id": client_id,
"tally_guid": tally_guid,
"date_from": date_from,
"date_to": date_to,
"status": "starting",
"stage": "Preparing local SQLite cache in background",
"current_date": "",
"total_days": total_days,
"completed_days": 0,
"percent": 0,
"vouchers_cached": 0,
"tally_requests": 0,
"retry_count": 0,
"pause_seconds": pause_seconds,
"error": "",
"started_at_utc": now,
"updated_at_utc": now,
"completed_at_utc": None,
"background": True,
"sqlite_cache": True,
"can_resume": False,
"can_cancel": True,
}
with _CASH_CACHE_THREADS_LOCK: with _CASH_CACHE_THREADS_LOCK:
if _CASH_CACHE_BUSY.is_set(): if _CASH_CACHE_BUSY.is_set() or any(t.is_alive() for t in _CASH_CACHE_THREADS.values()):
if prior and str(prior.get("status") or "") in {"queued", "running", "cooling"}:
return {"job": self._cash_payment_cache_status({"client_id": client_id, "job_id": prior["job_id"]})["job"], "resumed": False, "already_running": True}
raise ValueError("Another Tally extraction is already running on this workstation. Please wait for it to finish.") raise ValueError("Another Tally extraction is already running on this workstation. Please wait for it to finish.")
_CASH_CACHE_STARTING[job_id] = dict(starting)
_CASH_CACHE_CANCEL_REQUESTS.discard(job_id)
# IMPORTANT: starting a cache job must never call TallyPrime. The UI/ERP worker_payload = dict(payload)
# command must receive a job id immediately, while all Tally I/O happens worker_payload["tally_pause_seconds"] = pause_seconds
# only inside the background worker. Resolve the mapped company name thread = threading.Thread(
# from the local .act SQLite store; the worker validates the actually target=self._initialize_and_run_cash_payment_cache_job,
# open Tally company after the command result has already been returned. args=(job_id, worker_payload),
mapping = self.store.get_active_mapping_by_guid(client_id, tally_guid) name=f"tally-cash-cache-{job_id[:8]}",
company_name = str(mapping.get("company_name") or "").strip() daemon=True,
if not company_name: )
raise ValueError("The mapped Tally company name is not available in local accounting storage. Refresh the Tally mapping first.") with _CASH_CACHE_THREADS_LOCK:
_CASH_CACHE_THREADS[job_id] = thread
thread.start()
return {"job": dict(starting), "resumed": False, "already_running": False, "instant_ack": True}
def _initialize_and_run_cash_payment_cache_job(self, job_id: str, payload: dict[str, Any]) -> None:
client_id = int(payload.get("client_id"))
tally_guid = str(payload.get("tally_guid") or "").strip()
date_from = str(payload.get("date_from") or "").strip()
date_to = str(payload.get("date_to") or "").strip()
start_day = _dt_date.fromisoformat(date_from)
end_day = _dt_date.fromisoformat(date_to)
total_days = (end_day - start_day).days + 1
pause_seconds = max(2.0, min(float(payload.get("tally_pause_seconds") or 3.0), 15.0)) pause_seconds = max(2.0, min(float(payload.get("tally_pause_seconds") or 3.0), 15.0))
settings = { settings = {
"cash_limit": float(payload.get("cash_limit") or 10000.0), "cash_limit": float(payload.get("cash_limit") or 10000.0),
@@ -788,49 +820,94 @@ class AgentCommandProcessor:
"requested_by_user_id": payload.get("requested_by_user_id"), "requested_by_user_id": payload.get("requested_by_user_id"),
} }
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
resume = bool(prior and str(prior.get("status") or "") in {"failed", "paused", "interrupted", "running", "cooling"}) try:
job_id = str(prior.get("job_id")) if resume else str(uuid.uuid4()) with _CASH_CACHE_THREADS_LOCK:
total_days = (end_day - start_day).days + 1 if job_id in _CASH_CACHE_CANCEL_REQUESTS:
with self.store.connect(client_id) as db: state = _CASH_CACHE_STARTING.get(job_id)
if resume: if state is not None:
db.execute( state.update({"status": "cancelled", "stage": "Cancelled before SQLite initialization", "can_cancel": False, "completed_at_utc": now, "updated_at_utc": now})
"""UPDATE cash_payment_cache_jobs SET status='queued', stage='Queued for resume', error_message=NULL, return
pause_seconds=?, settings_json=?, updated_at_utc=? WHERE job_id=?""",
(pause_seconds, json.dumps(settings, separators=(",", ":")), now, job_id),
)
else:
db.execute(
"""INSERT INTO cash_payment_cache_jobs(
job_id, client_id, tally_guid, company_name, date_from, date_to, status, stage,
total_days, completed_days, vouchers_cached, tally_requests, retry_count,
pause_seconds, settings_json, started_at_utc, updated_at_utc
) VALUES (?, ?, ?, ?, ?, ?, 'queued', 'Queued', ?, 0, 0, 0, 0, ?, ?, ?, ?)""",
(job_id, client_id, tally_guid, company_name, date_from, date_to, total_days, pause_seconds,
json.dumps(settings, separators=(",", ":")), now, now),
)
cursor = start_day
while cursor <= end_day:
db.execute(
"INSERT INTO cash_payment_cache_days(job_id, voucher_date, status, updated_at_utc) VALUES (?, ?, 'pending', ?)",
(job_id, cursor.isoformat(), now),
)
cursor += _timedelta(days=1)
worker_payload = dict(payload) if not self.store.exists(client_id):
worker_payload["tally_pause_seconds"] = pause_seconds raise ValueError("Accounting local storage is not initialized for this client.")
worker_payload["company_name"] = company_name self._ensure_cash_cache_schema(client_id)
with _CASH_CACHE_THREADS_LOCK: mapping = self.store.get_active_mapping_by_guid(client_id, tally_guid)
_CASH_CACHE_CANCEL_REQUESTS.discard(job_id) company_name = str(mapping.get("company_name") or "").strip()
thread = threading.Thread( if not company_name:
target=self._run_cash_payment_cache_job, raise ValueError("The mapped Tally company name is not available in local accounting storage. Refresh the Tally mapping first.")
args=(job_id, worker_payload, dict(mapping)),
name=f"tally-cash-cache-{job_id[:8]}", # Reuse a paused/interrupted identical cache only inside the worker.
daemon=True, with self.store.connect(client_id) as db:
) prior = db.execute(
with _CASH_CACHE_THREADS_LOCK: """SELECT * FROM cash_payment_cache_jobs
_CASH_CACHE_THREADS[job_id] = thread WHERE client_id=? AND tally_guid=? AND date_from=? AND date_to=?
thread.start() ORDER BY started_at_utc DESC LIMIT 1""",
return {"job": self._cash_payment_cache_status({"client_id": client_id, "job_id": job_id})["job"], "resumed": resume, "already_running": False} (client_id, tally_guid, date_from, date_to),
).fetchone()
prior = dict(prior) if prior else None
resume = bool(prior and str(prior.get("status") or "") in {"failed", "paused", "interrupted", "running", "cooling", "cancelled"})
if resume:
db_job_id = str(prior.get("job_id"))
# Keep the instant-ACK id stable in the UI by copying the
# prior checkpoints into this new job id in one transaction.
db.execute(
"""INSERT INTO cash_payment_cache_jobs(
job_id, client_id, tally_guid, company_name, date_from, date_to, status, stage,
total_days, completed_days, vouchers_cached, tally_requests, retry_count,
pause_seconds, settings_json, cash_ledgers_json, started_at_utc, updated_at_utc
) VALUES (?, ?, ?, ?, ?, ?, 'queued', 'Queued for resume', ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)""",
(job_id, client_id, tally_guid, company_name, date_from, date_to, total_days,
int(prior.get("completed_days") or 0), int(prior.get("vouchers_cached") or 0), pause_seconds,
json.dumps(settings, separators=(",", ":")), str(prior.get("cash_ledgers_json") or "[]"), now, now),
)
rows = db.execute(
"SELECT voucher_date, status, voucher_count, attempts, last_error FROM cash_payment_cache_days WHERE job_id=? ORDER BY voucher_date",
(db_job_id,),
).fetchall()
db.executemany(
"INSERT INTO cash_payment_cache_days(job_id, voucher_date, status, voucher_count, attempts, last_error, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?)",
[(job_id, r[0], r[1], int(r[2] or 0), int(r[3] or 0), r[4], now) for r in rows],
)
else:
db.execute(
"""INSERT INTO cash_payment_cache_jobs(
job_id, client_id, tally_guid, company_name, date_from, date_to, status, stage,
total_days, completed_days, vouchers_cached, tally_requests, retry_count,
pause_seconds, settings_json, started_at_utc, updated_at_utc
) VALUES (?, ?, ?, ?, ?, ?, 'queued', 'Queued', ?, 0, 0, 0, 0, ?, ?, ?, ?)""",
(job_id, client_id, tally_guid, company_name, date_from, date_to, total_days, pause_seconds,
json.dumps(settings, separators=(",", ":")), now, now),
)
dates = []
cursor = start_day
while cursor <= end_day:
dates.append((job_id, cursor.isoformat(), "pending", now))
cursor += _timedelta(days=1)
db.executemany(
"INSERT INTO cash_payment_cache_days(job_id, voucher_date, status, updated_at_utc) VALUES (?, ?, ?, ?)",
dates,
)
payload["company_name"] = company_name
with _CASH_CACHE_THREADS_LOCK:
_CASH_CACHE_STARTING.pop(job_id, None)
self._run_cash_payment_cache_job(job_id, payload, dict(mapping))
except Exception as exc:
self.logger.exception("Cash payment cache initialization failed job_id=%s: %s", job_id, exc)
failed_at = datetime.now(timezone.utc).isoformat()
try:
if self.store.exists(client_id):
self._ensure_cash_cache_schema(client_id)
row = self._cash_cache_job_row(client_id, job_id)
if row:
self._cash_cache_update(client_id, job_id, status="paused", stage="Initialization paused safely", error_message=str(exc), completed_at_utc=failed_at)
except Exception:
pass
with _CASH_CACHE_THREADS_LOCK:
state = _CASH_CACHE_STARTING.get(job_id)
if state is not None:
state.update({"status": "paused", "stage": "Initialization paused safely", "error": str(exc), "can_cancel": False, "can_resume": True, "completed_at_utc": failed_at, "updated_at_utc": failed_at})
_CASH_CACHE_THREADS.pop(job_id, None)
def _run_cash_payment_cache_job(self, job_id: str, payload: dict[str, Any], mapping: dict[str, Any]) -> None: def _run_cash_payment_cache_job(self, job_id: str, payload: dict[str, Any], mapping: dict[str, Any]) -> None:
client_id = int(payload.get("client_id")) client_id = int(payload.get("client_id"))
@@ -948,6 +1025,13 @@ class AgentCommandProcessor:
def _cash_payment_cache_cancel(self, payload: dict[str, Any]) -> dict[str, Any]: def _cash_payment_cache_cancel(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id")) client_id = int(payload.get("client_id"))
job_id = str(payload.get("job_id") or "").strip() job_id = str(payload.get("job_id") or "").strip()
with _CASH_CACHE_THREADS_LOCK:
mem = _CASH_CACHE_STARTING.get(job_id)
if mem is not None:
_CASH_CACHE_CANCEL_REQUESTS.add(job_id)
mem["stage"] = "Cancellation requested; stopping safely"
mem["updated_at_utc"] = datetime.now(timezone.utc).isoformat()
return {"job": dict(mem), "cancel_requested": True}
row = self._cash_cache_job_row(client_id, job_id) row = self._cash_cache_job_row(client_id, job_id)
if not row: if not row:
raise ValueError("Cash payment cache job was not found on this workstation.") raise ValueError("Cash payment cache job was not found on this workstation.")
@@ -962,6 +1046,10 @@ class AgentCommandProcessor:
def _cash_payment_cache_status(self, payload: dict[str, Any]) -> dict[str, Any]: def _cash_payment_cache_status(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id")) client_id = int(payload.get("client_id"))
job_id = str(payload.get("job_id") or "").strip() job_id = str(payload.get("job_id") or "").strip()
with _CASH_CACHE_THREADS_LOCK:
mem = _CASH_CACHE_STARTING.get(job_id)
if mem is not None:
return {"job": dict(mem), "agent": self._agent_info()}
row = self._cash_cache_job_row(client_id, job_id) row = self._cash_cache_job_row(client_id, job_id)
if not row: if not row:
raise ValueError("Cash payment cache job was not found on this workstation.") raise ValueError("Cash payment cache job was not found on this workstation.")
@@ -990,7 +1078,7 @@ class AgentCommandProcessor:
"background": True, "background": True,
"sqlite_cache": True, "sqlite_cache": True,
"can_resume": str(row.get("status") or "") in {"paused", "failed", "interrupted", "cancelled"}, "can_resume": str(row.get("status") or "") in {"paused", "failed", "interrupted", "cancelled"},
"can_cancel": str(row.get("status") or "") in {"queued", "running", "cooling"}, "can_cancel": str(row.get("status") or "") in {"queued", "running", "cooling", "starting"},
}, },
"agent": self._agent_info(), "agent": self._agent_info(),
} }