#!/usr/bin/python # -*- coding: utf-8 -*- # # queue.py # # Copyright (c) 2018, Paul Holleis, Marko Luther # All rights reserved. # # # ABOUT # This module connects to the artisan.plus inventory management service # LICENSE # This program or module is free software: you can redistribute it and/or # modify it under the terms of the GNU General Public License as published # by the Free Software Foundation, either version 2 of the License, or # version 3 of the License, or (at your option) any later versison. It is # provided for educational purposes and is distributed in the hope that # it will be useful, but WITHOUT ANY WARRANTY; without even the implied # warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See # the GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import persistqueue import threading import time from requests.exceptions import ConnectionError from PyQt5.QtCore import QCoreApplication from PyQt5.QtWidgets import QApplication from plus import config, util, roast, connection, sync, controller queue_path = util.getDirectory(config.outbox_cache,share=True) app = QCoreApplication.instance() queue = persistqueue.SQLiteQueue(queue_path,multithreading=True,auto_commit=False) # auto_commit=False : we keep items in the queue if not explicit marked as task_done # queue entries are dictionaries with entries # url : the URL to send the request to # data : the data dictionary that will be send in the body as JSON # verb : the HTTP verb to be used (POST or PUT) worker_thread = None class Concur(threading.Thread): def __init__(self): threading.Thread.__init__(self) self.daemon = True # OK for main to exit even if instance is still running self.paused = False # start out non-paused self.state = threading.Condition() def addSyncItem(self,item): # successfully transmitted, we add/update the roasts UUID sync-cache if "roast_id" in item["data"] and "modified_at" in item["data"]: # we update the plus status icon sync.addSync(item["data"]["roast_id"],util.ISO86012epoch(item["data"]["modified_at"])) config.app_window.updatePlusStatusSignal.emit() # @UndefinedVariable def run(self): global queue config.logger.debug("queue:run()") time.sleep(config.queue_start_delay) self.resume() # unpause self item = None while True: time.sleep(config.queue_task_delay) with self.state: if self.paused: self.state.wait() # block until notified config.logger.debug("queue: -> qsize: %s",queue.qsize()) config.logger.debug("queue: looking for next item to be fetched") try: if item is None: item = queue.get() time.sleep(config.queue_task_delay) config.logger.debug("queue: -> worker processing item: %s",item) iters = config.queue_retries + 1 while iters > 0: config.logger.debug("queue: -> remaining iterations: %s",iters) r = None try: # we upload only full roast records, or partial updates in case the are under sync (registered in the sync cache) if is_full_roast_record(item["data"]) or ("roast_id" in item["data"] and sync.getSync(item["data"]["roast_id"])): controller.connect(clear_on_failure=False,interactive=False) r = connection.sendData(item["url"],item["data"],item["verb"]) r.raise_for_status() # successfully transmitted, we add/update the roasts UUID sync-cache iters = 0 self.addSyncItem(item) else: # partial sync updates for roasts not registered for syncing are ignored iters = 0 except ConnectionError as e: try: if controller.is_connected(): config.logger.debug("queue: -> connection error, disconnecting: %s",e) # we disconnect, but keep the queue running to let it automatically reconnect if possible controller.disconnect(remove_credentials = False, stop_queue=False) except: pass # we don't change the iter, but retry to connect after a delay in the next iteration time.sleep(config.queue_retry_delay) except Exception as e: config.logger.debug("queue: -> task failed: %s",e) if r is not None: config.logger.debug("queue: -> status code %s",r.status_code) else: config.logger.debug("queue: -> no status code") if r is not None and r.status_code == 401: # authentication failed try: if controller.is_connected(): config.logger.debug("queue: -> connection error, disconnecting: %s",e) # we disconnect, but keep the queue running to let it automatically reconnect if possible controller.disconnect(remove_credentials = False, stop_queue=False) except: pass iters = iters - 1 # we retry to connect after a delay in the next iteration time.sleep(config.queue_retry_delay) elif r is not None and r.status_code == 409: # conflict iters = 0 # we don't retry, but remove the task as it is faulty else: # 500 internal server error, 429 Client Error: Too Many Requests, 404 Client Error: Not Found or others # something went wrong we don't mark this task as done and retry iters = iters - 1 time.sleep(config.queue_retry_delay) # we call task_done to remove the item from the queue queue.task_done() item = None config.logger.debug("queue: -> task done") config.logger.debug("queue: end of run:while paused=%s",self.paused) except Exception as e: pass def resume(self): config.logger.info("queue:resume()") with self.state: self.paused = False self.state.notify() # unblock self if waiting def pause(self): config.logger.info("queue:pause()") with self.state: self.paused = True # make self block and wait def start(): if app.artisanviewerMode: config.logger.info("queue:start(): queue not started in ArtisanViewer mode") else: global queue global worker_thread config.logger.info("queue:start()") config.logger.debug("queue: -> qsize: %s",queue.qsize()) if worker_thread is None: worker_thread = Concur() worker_thread.setDaemon(True) # a daemon thread is automatically killed on program exit worker_thread.start() else: worker_thread.resume() # the queue worker thread cannot really be stopped, but we can pause it def stop(): if not app.artisanviewerMode: config.logger.info("queue:stop()") worker_thread.pause() # check if a full roast record (one with date) with roast_id is in the queue # this is used to add only items to the queue that are registered already in the sync cache # but not yet uploaded as they are still in the queue def full_roast_in_queue(roast_id): q = persistqueue.SQLiteQueue(queue_path,multithreading=True,auto_commit=False) try: while True: item = q.get(block=False) if "data" in item: r = item["data"] if is_full_roast_record(r) and roast_id == r["roast_id"]: # there is a full roast record already in queue break del q return True except: # we reached the end of the queue del q return False ################ # returns true if the given roast_record r is a full record containing all information (incl. the roast date) and not only an update def is_full_roast_record(r): return "date" in r and r["date"] and "amount" in r and "roast_id" in r # called on completed roasts with roast data # if roast_record is given, we assume an update is queued, otherwise a new roast is queued # a full roast_record requires at least # - roast_id # - date # - amount # an update only the roast_id def addRoast(roast_record = None): global queue try: config.logger.info("queue:addRoast()") if config.app_window.plus_readonly: config.logger.info("queue: -> roast not queued as users account access is readonly") else: if roast_record == None: r = roast.getRoast() else: r = roast_record # if modification date is not set yet, we add the current time as modified_at timestamp as float EPOCH with millisecond if not "modified_at" in r: r["modified_at"] = util.epoch2ISO8601(time.time()) config.logger.debug("queue: -> roast: %s",r) # check if all required data is available before queueing this up if "roast_id" in r and r["roast_id"] and \ (roast_record is not None or ("date" in r and r["date"] and "amount" in r)): # amount can be 0 but has to be present # put in upload queue config.logger.debug("queue: -> put in queue") config.app_window.sendmessage(QApplication.translate("Plus","Queuing roast for upload to artisan.plus",None)) # @UndefinedVariable queue.put({ "url": config.roast_url, "data": r, "verb": "POST"}, # timeout=config.queue_put_timeout # sql queue does not feature a timeout ) config.logger.debug("queue: -> roast queued up") config.logger.debug("queue: -> qsize: %s",queue.qsize()) sync.setSyncRecordHash(r) else: config.logger.debug("queue: -> roast not queued as mandatory info missing") except Exception as e: import sys _, _, exc_tb = sys.exc_info() config.logger.error("queue: Exception in addRoast() in line %s: %s",exc_tb.tb_lineno,e)