Repository: spotify/luigi

Issue:
Tasks that complete in <1s may display as RUNNING or PENDING in task history
Using MySQL backend, I am seeing tasks that have completed in either "RUNNING" or "PENDING" state in the task_history webpage.  Looking at the db there is a row marking these tasks as "DONE" but their timestamps clash with the displayed event.  Clearly the timestamps aren't sub-second precision.

E.g.

```
mysql> select id, event_name, ts from task_events where task_id=61;
+-----+------------+---------------------+
| id  | event_name | ts                  |
+-----+------------+---------------------+
| 181 | PENDING    | 2015-04-22 15:10:02 |
| 230 | RUNNING    | 2015-04-22 15:12:38 |
| 231 | DONE       | 2015-04-22 15:12:38 |
+-----+------------+---------------------+
3 rows in set (0.00 sec)
```

Modern MySQL should support this.  I have found this stackoverflow question suggesting the issue is with SQLAlchemy: http://stackoverflow.com/questions/29711102/sqlalchemy-mysql-millisecond-or-microsecond-precision

Other than fixing this in SQLAlchemy it might work to simply sort task_event rows by timestamp _and_ primary key, assuming the events are inserted in true order.



Discussion:
As per the SO post.  You need to use the `sqlalchemy.dialects.mysql.TIMESTAMP` column as `TIMESTAMP(fsp=6)` to get sub-second precision.

I suggest db_taskhistory.py could use this when using mysql.

Why don't you send a PR @stephenpascoe ? :)

Sure, PR should follow soon. 


Resolving patch:
diff --git a/luigi/db_task_history.py b/luigi/db_task_history.py
--- a/luigi/db_task_history.py
+++ b/luigi/db_task_history.py
@@ -198,7 +198,7 @@ class TaskEvent(Base):
     id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
     task_id = sqlalchemy.Column(sqlalchemy.Integer, sqlalchemy.ForeignKey('tasks.id'))
     event_name = sqlalchemy.Column(sqlalchemy.String(20))
-    ts = sqlalchemy.Column(sqlalchemy.TIMESTAMP, index=True)
+    ts = sqlalchemy.Column(sqlalchemy.TIMESTAMP, index=True, nullable=False)
 
     def __repr__(self):
         return "TaskEvent(task_id=%s, event_name=%s, ts=%s" % (self.task_id, self.event_name, self.ts)
@@ -220,7 +220,7 @@ class TaskRecord(Base):
         cascade="all, delete-orphan")
     events = sqlalchemy.orm.relationship(
         'TaskEvent',
-        order_by=lambda: TaskEvent.ts.desc(),
+        order_by=(sqlalchemy.desc(TaskEvent.ts), sqlalchemy.desc(TaskEvent.id)),
         backref='task')
 
     def __repr__(self):
diff --git a/luigi/server.py b/luigi/server.py
--- a/luigi/server.py
+++ b/luigi/server.py
@@ -146,11 +146,23 @@ def get(self, name):
         self.render('history.html', name=name, statusResults=statusResults, taskResults=taskResults)
 
 
-def from_utc(utcTime, fmt="%Y-%m-%d %H:%M:%S.%f"):
+def from_utc(utcTime, fmt=None):
     """convert UTC time string to time.struct_time: change datetime.datetime to time, return time.struct_time type"""
-    time_struct = datetime.datetime.strptime(utcTime, fmt)
-    date = int(time.mktime(time_struct.timetuple()))
-    return date
+    if fmt is None:
+        try_formats = ["%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"]
+    else:
+        try_formats = [fmt]
+
+    for fmt in try_formats:
+        try:
+            time_struct = datetime.datetime.strptime(utcTime, fmt)
+        except ValueError:
+            pass
+        else:
+            date = int(time.mktime(time_struct.timetuple()))
+            return date
+    else:
+        raise ValueError("No UTC format matches {}".format(utcTime))
 
 
 class RecentRunHandler(BaseTaskHistoryHandler):


Test patch:
diff --git a/test/db_task_history_test.py b/test/db_task_history_test.py
--- a/test/db_task_history_test.py
+++ b/test/db_task_history_test.py
@@ -21,7 +21,7 @@
 
 from helpers import with_config
 import luigi
-from luigi.db_task_history import DbTaskHistory
+from luigi.db_task_history import DbTaskHistory, Base
 from luigi.task_status import DONE, PENDING, RUNNING
 
 
@@ -82,3 +82,37 @@ def run_task(self, task):
         self.history.task_scheduled(task.task_id)
         self.history.task_started(task.task_id, 'hostname')
         self.history.task_finished(task.task_id, successful=True)
+
+
+class MySQLDbTaskHistoryTest(unittest.TestCase):
+
+    @with_config(dict(task_history=dict(db_connection='mysql+mysqlconnector://travis@localhost/luigi_test')))
+    def setUp(self):
+        self.history = DbTaskHistory()
+
+    def test_subsecond_timestamp(self):
+        # Add 2 events in <1s
+        task = DummyTask()
+        self.run_task(task)
+
+        task_record = six.advance_iterator(self.history.find_all_by_name('DummyTask'))
+        print (task_record.events)
+        self.assertEqual(task_record.events[0].event_name, DONE)
+
+    def test_utc_conversion(self):
+        from luigi.server import from_utc
+
+        task = DummyTask()
+        self.run_task(task)
+
+        task_record = six.advance_iterator(self.history.find_all_by_name('DummyTask'))
+        last_event = task_record.events[0]
+        try:
+            print (from_utc(str(last_event.ts)))
+        except ValueError:
+            self.fail("Failed to convert timestamp {} to UTC".format(last_event.ts))
+
+    def run_task(self, task):
+        self.history.task_scheduled(task.task_id)
+        self.history.task_started(task.task_id, 'hostname')
+        self.history.task_finished(task.task_id, successful=True)
diff --git a/test/requirements.txt b/test/requirements.txt
--- a/test/requirements.txt
+++ b/test/requirements.txt
@@ -8,3 +8,4 @@ boto
 sqlalchemy
 elasticsearch
 snakebite>=2.5.2
+mysql-connector-python