diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,13 @@
+Copyright (c) 2016 Position Development LLC
+
+Permission to use, copy, modify, and/or distribute this software for any purpose
+with or without fee is hereby granted, provided that the above copyright notice
+and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
+OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
+TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+THIS SOFTWARE.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/example/Main.hs b/example/Main.hs
new file mode 100644
--- /dev/null
+++ b/example/Main.hs
@@ -0,0 +1,13 @@
+{-# LANGUAGE OverloadedStrings #-}
+import           Control.Concurrent (forkIO, threadDelay)
+import           Control.Monad      (forever)
+import qualified Data.Text.IO       as T
+import qualified Database.Redis     as R
+import           System.Periodic
+main = do rconn <- R.connect R.defaultConnectInfo
+          scheduler <- create (Name "default") rconn (CheckInterval (Seconds 1)) (LockTimeout (Seconds 1000)) T.putStrLn
+          addTask scheduler "print-hello-job" (Every (Seconds 10)) (T.putStrLn "hello")
+          addTask scheduler "print-bye-job" (Every (Seconds 1)) (T.putStrLn "bye")
+          forkIO (run scheduler)
+          forkIO (run scheduler)
+          forever (threadDelay 1000000)
diff --git a/periodic.cabal b/periodic.cabal
new file mode 100644
--- /dev/null
+++ b/periodic.cabal
@@ -0,0 +1,53 @@
+name:                periodic
+version:             0.1.0.0
+synopsis:            A reliable at-least-once periodic job scheduler backed by redis.
+description:         Please see README.md
+homepage:            https://github.com/positiondev/periodic
+license:             ISC
+license-file:        LICENSE
+author:              Daniel Patterson
+maintainer:          workers@positiondev.com
+copyright:           2016 Position Development LLC
+category:            Web
+build-type:          Simple
+-- extra-source-files:
+cabal-version:       >=1.10
+
+library
+  hs-source-dirs:      src
+  exposed-modules:     System.Periodic
+  build-depends:       base >= 4.7 && < 5
+                     , text
+                     , time
+                     , cereal
+                     , hedis
+  default-language:    Haskell2010
+
+executable example
+  hs-source-dirs:     example
+  main-is: Main.hs
+  build-depends:       base >= 4.7 && < 5
+                     , text
+                     , time
+                     , cereal
+                     , hedis
+                     , periodic
+  default-language:    Haskell2010
+
+
+test-suite periodic-test
+  type:                exitcode-stdio-1.0
+  hs-source-dirs:      test, src
+  main-is:             Spec.hs
+  build-depends:       base
+                     , text
+                     , time
+                     , hedis
+                     , hspec
+                     , cereal
+  ghc-options:         -threaded -rtsopts -with-rtsopts=-N
+  default-language:    Haskell2010
+
+source-repository head
+  type:     git
+  location: https://github.com/positiondev/periodic
diff --git a/src/System/Periodic.hs b/src/System/Periodic.hs
new file mode 100644
--- /dev/null
+++ b/src/System/Periodic.hs
@@ -0,0 +1,236 @@
+{-# LANGUAGE OverloadedStrings     #-}
+{-# LANGUAGE PartialTypeSignatures #-}
+{-# LANGUAGE ScopedTypeVariables   #-}
+
+{-|
+
+This module contains a very basic periodic job processor backed by
+Redis, suitable to be run by clusters of machines (jobs will be run at
+least once per scheduled time, and will not run more than once unless
+the (configurable) timeout is reached, at which point retry
+occurs. There is also no guarantee that distribution of jobs across
+machines will be fair). It depends upon Redis not losing data once it
+has acknowledged it, and guaranteeing the atomicity that is specified
+for commands like EVAL (ie, that if you do several things within an
+EVAL, they will all happen or none will happen). Nothing has been
+tested with Redis clusters (and it likely will not work).
+
+An example use is the following (provided in repository), noting that
+we create two threads to process jobs, and could run this entire
+executable many times and the jobs would only run at the scheduled
+time.
+
+> {-# LANGUAGE OverloadedStrings #-}
+> import           Control.Monad      (forever)
+> import           System.Periodic
+> import           Control.Concurrent      (threadDelay, forkIO)
+> import qualified Data.Text.IO            as T
+> import qualified Database.Redis          as R
+> main = do rconn <- R.connect R.defaultConnectInfo
+>           scheduler <- create "default" rconn (CheckInterval (Seconds 1)) (LockTimeout (Seconds 1000)) (T.putStrLn)
+>           addTask scheduler "print-hello-job" (Every (Seconds 100)) (T.putStrLn "hello")
+>           addTask scheduler "print-bye-job" (Every (Seconds 10)) (T.putStrLn "bye")
+>           forkIO (run scheduler)
+>           forkIO (run scheduler)
+>           forever (threadDelay 1000000)
+
+
+-}
+
+module System.Periodic
+       ( -- * Types
+         Time(..)
+       , Seconds(..)
+       , Period(..)
+       , Name(..)
+       , CheckInterval(..)
+       , LockTimeout(..)
+       , Logger
+       , Scheduler
+       -- * Managing Schedulers
+       , create
+       , run
+       , destroy
+       -- * Scheduling Tasks
+       , addTask
+       ) where
+
+import           Control.Concurrent      (forkIO, threadDelay)
+import           Control.Concurrent.MVar
+import           Control.Exception       (SomeException, catch)
+import           Control.Monad           (forever, join, when)
+import           Data.Monoid
+import           Data.Ratio
+import           Data.Serialize
+import           Data.Text               (Text)
+import qualified Data.Text               as T
+import qualified Data.Text.Encoding      as T
+import           Data.Time.Calendar
+import           Data.Time.Clock
+import           Data.Time.Clock.POSIX
+import qualified Database.Redis          as R
+
+
+-- | Time from midnight UTC
+newtype Time = Time DiffTime
+
+newtype Seconds = Seconds Int
+
+-- | When the job should run - either at a particular offset from
+-- midnight UTC, or every N seconds.
+data Period = Daily Time | Every Seconds
+
+-- | The name of the scheduler. This, when combined with the task
+-- name, should be unique on a given Redis server.
+newtype Name = Name Text
+
+-- | How frequently we should check if jobs need to get run. If all
+-- your jobs are infrequent (daily, or occurring hours apart), setting
+-- this to a high number (every minute, or more) decreases the number
+-- of queries to Redis.
+newtype CheckInterval = CheckInterval Seconds
+
+-- | How long a job that has been started and not marked as finished
+-- should take before we run it again. Note that if you put this
+-- number above the period, we will never retry the jobs (but they
+-- will still keep running every period).
+newtype LockTimeout = LockTimeout Seconds
+
+-- | A function where log messages are sent.
+type Logger = Text -> IO ()
+
+-- | Internal type representing the current tasks that are scheduled.
+data Scheduler = Scheduler { schedulerName          :: Name
+                           , schedulerRedisConn     :: R.Connection
+                           , schedulerTasks         :: MVar [(Text, Period, IO ())]
+                           , schedulerCheckInterval :: CheckInterval
+                           , schedulerLockTimout    :: LockTimeout
+                           , schedulerLogger        :: Logger
+                           }
+
+-- | This function creates a new scheduler, which can then have tasks
+-- scheduled in it. The tasks themselves are _not_ stored in Redis; it
+-- is only used to ensure we run the tasks at the right time (and not
+-- more than once across multiple machines). Note that this does not
+-- actually run any of the tasks - you must fork threads that call
+-- 'run'.
+create :: Name
+       -> R.Connection
+       -> CheckInterval
+       -> LockTimeout
+       -> (Text -> IO ())
+       -> IO Scheduler
+create name rconn check lock logger =
+  do mv <- newMVar []
+     return $ Scheduler name rconn mv check lock logger
+
+-- | Add a new task to a given scheduler, to be run at the specified
+-- period. Note that the name, when combined with the scheduler name,
+-- should be unique for the given redis database, or else only one of
+-- the jobs with the same name will be run.
+addTask :: Scheduler -> Text -> Period -> IO () -> IO ()
+addTask scheduler name when act =
+  do modifyMVar_ (schedulerTasks scheduler) (return . (:) (name, when, act))
+     return ()
+
+lockTimeout = 10 * 3600
+
+-- | Start a scheduler thread. You must start at least one, but can
+-- start multiple threads if you have many tasks that take a long time
+-- to run.
+run :: Scheduler -> IO ()
+run (Scheduler (Name nm) rconn mv (CheckInterval (Seconds check)) lock logger) = forever $
+    do now <- getCurrentTime
+       tasks <- readMVar mv
+       mapM_ (tryRunTask logger lock nm rconn now) tasks
+       threadDelay (check * 1000000)
+
+lastStartedKey pname name =
+  T.encodeUtf8 $ pname <> "-" <> name <> "-last-started"
+lockedKey pname name =
+  T.encodeUtf8 $ pname <> "-" <> name <> "-running-at"
+
+-- | This clears out redis of any mention of tasks related to the
+-- scheduler. It isn't necessary to do in normal scenarios.
+destroy :: Scheduler -> IO ()
+destroy (Scheduler (Name nm) rconn mv _ _ _) =
+  do tasks <- readMVar mv
+     R.runRedis rconn $
+       R.del (concatMap (\(tnm,_,_) ->
+                            [lastStartedKey nm tnm
+                            ,lockedKey nm tnm])
+                        tasks)
+     return ()
+
+collapseError :: Either a (Maybe b) -> Maybe b
+collapseError (Left _) = Nothing
+collapseError (Right v) = v
+
+collapseNumberBoolFalse :: Either R.Reply (Maybe Integer) -> Bool
+collapseNumberBoolFalse (Left e) = error (show e)
+collapseNumberBoolFalse (Right (Just 1)) = True
+collapseNumberBoolFalse (Right (Just 0)) = False
+
+
+parseUnixTime s = let Right (n, d) = decode s in posixSecondsToUTCTime $ fromRational $ n % d
+
+renderUnixTime t = let r =  toRational . utcTimeToPOSIXSeconds $ t in encode (numerator r, denominator r)
+
+minutes5 = 60 * 5
+
+shouldRun :: LockTimeout -> Period -> Maybe UTCTime -> Maybe UTCTime -> UTCTime -> Bool
+shouldRun (LockTimeout (Seconds timeout)) _ _ (Just locked) now
+  = diffUTCTime now locked > fromIntegral timeout
+shouldRun _ (Daily (Time t)) Nothing _ now
+  | now > (now { utctDayTime = t }) && utctDayTime now - t < minutes5
+  = True
+shouldRun _ (Daily (Time t)) (Just last) _ now
+  | now > (now { utctDayTime = t }) && last < (now { utctDayTime = t })
+  = True
+shouldRun _ (Every (Seconds n)) Nothing _ now
+  = True
+shouldRun _ (Every (Seconds n)) (Just last) locked now
+  = diffUTCTime now last > fromIntegral n
+shouldRun lock period last locked now = False
+
+
+tryRunTask :: Logger -> LockTimeout -> Text -> R.Connection -> UTCTime -> (Text, Period, IO ()) -> IO ()
+tryRunTask logger timeout pname rconn now (name, period, task) =
+  do lastStarted <- fmap parseUnixTime . collapseError <$>
+                    R.runRedis rconn
+                               (R.get (lastStartedKey pname name))
+     lockedAt <- fmap parseUnixTime . collapseError <$>
+                 R.runRedis rconn
+                            (R.get (lockedKey pname name))
+     when (shouldRun timeout period lastStarted lockedAt now) $
+       do gotLock <-
+            collapseNumberBoolFalse <$> R.runRedis rconn
+            (R.eval "local lock = redis.call('get', KEYS[1])\n\
+                    \local last = redis.call('get', KEYS[2])\n\
+                    \if ((lock == false and ARGV[1] == '0') or (lock == ARGV[1]))\
+                    \  and ((last == false and ARGV[2] == '0') or (last == ARGV[2])) then\n\
+                    \  redis.call('set', KEYS[1], ARGV[3])\n\
+                    \  redis.call('set', KEYS[2], ARGV[3])\n\
+                    \  return 1\n\
+                    \else\n\
+                    \  return 0\n\
+                    \end" [lockedKey pname name, lastStartedKey pname name]
+                          [maybe "0" renderUnixTime lockedAt
+                          ,maybe "0" renderUnixTime lastStarted
+                          ,renderUnixTime now])
+          when gotLock $
+            do x <- newEmptyMVar
+               jt <- forkIO (catch (task >> putMVar x Nothing)
+                                   (\(e::SomeException) ->
+                                      putMVar x (Just e)))
+               res <- takeMVar x
+               case res of
+                 Just e -> logger $ T.concat ["periodic["
+                                             ,pname
+                                             ,"::"
+                                             ,name
+                                             ,"] error: "
+                                             ,T.pack (show e)]
+                 Nothing -> return ()
+               R.runRedis rconn $ R.del [lockedKey pname name]
+               return ()
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,117 @@
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings     #-}
+import           Control.Concurrent      (forkIO, killThread, threadDelay)
+import           Control.Concurrent.MVar (MVar, modifyMVarMasked_, newMVar,
+                                          readMVar, takeMVar)
+import           Control.Monad           (replicateM)
+import qualified Data.Text               as T
+import           Data.Time.Calendar
+import           Data.Time.Clock
+import qualified Database.Redis          as R
+import           System.Periodic
+
+import           Test.Hspec
+
+
+date :: Integer -> Int -> Int -> UTCTime
+date y m d = UTCTime (fromGregorian y m d) 0
+
+time :: UTCTime -> Int -> Int -> Int -> UTCTime
+time t h m s = t { utctDayTime = fromIntegral $ h * 60 * 60 + m * 60 + s }
+
+runNThreads :: Int -> Int -> Scheduler -> IO ()
+runNThreads n delay sched = do threads <- replicateM n (forkIO $ run sched)
+                               threadDelay delay
+                               mapM_ killThread threads
+
+createSch :: T.Text -> IO Scheduler
+createSch n = createSch' n print
+
+createSch' :: T.Text -> Logger -> IO Scheduler
+createSch' n l = do rconn <- R.connect R.defaultConnectInfo
+                    create (Name n) rconn (CheckInterval (Seconds 1)) (LockTimeout (Seconds 1000)) l
+
+main :: IO ()
+main = hspec $
+  do describe "shouldRun" $
+       do it "should run if it's never been run, not locked and time just passed" $
+            shouldRun (LockTimeout (Seconds 1000)) (Daily (Time 5)) Nothing Nothing (time (date 2016 5 5) 0 0 6)
+          it "shouldn't run if it was locked recently" $
+            not $ shouldRun (LockTimeout (Seconds 1000)) (Daily (Time 5)) (Just (time (date 2016 5 4) 23 45 0)) (Just (time (date 2016 5 4) 23 45 0)) (time (date 2016 5 5) 0 0 6)
+          it "should run if it was locked a long time ago" $
+            shouldRun (LockTimeout (Seconds 1000))
+                      (Daily (Time 5))
+                      (Just (date 2016 5 4))
+                      (Just (date 2016 5 4))
+                      (date 2016 5 5)
+     describe "simple job" $
+       do it "should run " $
+            do mvar <- newMVar 0
+               scheduler <- createSch "simple-1"
+               addTask scheduler "job" (Every (Seconds 100)) (modifyMVarMasked_ mvar (return . (+1)))
+
+               runNThreads 1 30000 scheduler
+
+               destroy scheduler
+               v <- takeMVar mvar
+               1 `shouldBe` v
+          it "should only run once per scheduled time" $
+             do mvar <- newMVar 0
+                scheduler <- createSch "simple-2"
+                addTask scheduler "job" (Every (Seconds 100)) (modifyMVarMasked_ mvar (return . (+1)))
+                runNThreads 3 100000 scheduler
+                destroy scheduler
+                v <- takeMVar mvar
+                v `shouldBe` 1
+          it "should run at each time point" $
+             do mvar <- newMVar 0
+                scheduler <- createSch "simple-3"
+                addTask scheduler "job" (Every (Seconds 2)) (modifyMVarMasked_ mvar (return . (+1)))
+                runNThreads 3 4000000 scheduler
+                destroy scheduler
+                v <- takeMVar mvar
+                -- NOTE(dbp 2016-05-26): Precise timing is hard
+                -- without making the tests super slow.
+                (v == 3 || v == 2) `shouldBe` True
+          it "should run at scheduled time" $
+             do mvar <- newMVar 0
+                scheduler <- createSch "simple-4"
+                seconds <- utctDayTime <$> getCurrentTime
+                addTask scheduler "job" (Daily (Time seconds)) (modifyMVarMasked_ mvar (return . (+1)))
+                runNThreads 1 4000000 scheduler
+                destroy scheduler
+                v <- takeMVar mvar
+                v `shouldBe` 1
+          it "should not run at an unscheduled time" $
+             do mvar <- newMVar 0
+                scheduler <- createSch "simple-4"
+                now <- getCurrentTime
+                let seconds = utctDayTime $ addUTCTime 3600 now
+                addTask scheduler "job" (Daily (Time seconds)) (modifyMVarMasked_ mvar (return . (+1)))
+                runNThreads 1 2000000 scheduler
+                destroy scheduler
+                v <- takeMVar mvar
+                v `shouldBe` 0
+     describe "error handling" $
+       do it "should keep running jobs if one throws an exception" $
+            do mvar <- newMVar 0
+               scheduler <- createSch' "error-1" (const $ return ())
+               addTask scheduler "job-error" (Every (Seconds 100)) (error "blowing up")
+               thread <- forkIO $ run scheduler
+               threadDelay 300000
+               addTask scheduler "job-success" (Every (Seconds 100)) (modifyMVarMasked_ mvar (return . (+1)))
+               threadDelay 2000000
+               killThread thread
+               destroy scheduler
+               v <- takeMVar mvar
+               1 `shouldBe` v
+          it "should send exception to logger" $
+            do mvar <- newMVar []
+               scheduler <- createSch' "error-1" (\t -> modifyMVarMasked_ mvar (return . (t:)))
+               addTask scheduler "job-error" (Every (Seconds 100)) (error "blowing up")
+               thread <- forkIO $ run scheduler
+               threadDelay 500000
+               killThread thread
+               destroy scheduler
+               v <- takeMVar mvar
+               length v `shouldBe` 1
