Skip to content

Commit

Permalink
Merge pull request #1489 from hubotio/add_datastore_clean
Browse files Browse the repository at this point in the history
Add a new persistence model - Datastore
  • Loading branch information
mistydemeo authored Feb 19, 2019
2 parents 067a70d + 8392f0d commit d2b57bd
Show file tree
Hide file tree
Showing 9 changed files with 372 additions and 4 deletions.
11 changes: 11 additions & 0 deletions docs/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ There are two primary entry points for middleware:

## Persistence

### Brain

Hubot has a memory exposed as the `robot.brain` object that can be used to store and retrieve data.
Furthermore, Hubot scripts exist to enable persistence across Hubot restarts.
`hubot-redis-brain` is such a script and uses a backend Redis server.
Expand All @@ -44,3 +46,12 @@ By default, the brain contains a list of all users seen by Hubot.
Therefore, without persistence across restarts, the brain will contain the list of users encountered so far, during the current run of Hubot.
On the other hand, with persistence across restarts, the brain will contain all users encountered by Hubot during all of its runs.
This list of users can be accessed through `hubot.brain.users()` and other utility methods.

### Datastore

Hubot's optional datastore, exposed as the `robot.datastore` object, provides a more robust persistence model. Compared to the brain, the datastore:

1. Is always (instead of optionally) backed by a database
2. Fetches data from the database and stores data in the database on every request, instead of periodically persisting the entire in-memory brain.

The datastore is useful in cases where there's a need for greater reassurances of data integrity or in cases where multiple Hubot instances need to access the same database.
42 changes: 40 additions & 2 deletions docs/scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,8 +565,10 @@ The other sections are more relevant to developers of the bot, particularly depe

## Persistence

Hubot has an in-memory key-value store exposed as `robot.brain` that can be
used to store and retrieve data by scripts.
Hubot has two persistence methods available that can be
used to store and retrieve data by scripts: an in-memory key-value store exposed as `robot.brain`, and an optional persistent database-backed key-value store expsoed as `robot.datastore`

### Brain

```coffeescript
robot.respond /have a soda/i, (res) ->
Expand Down Expand Up @@ -600,6 +602,42 @@ module.exports = (robot) ->
res.send "#{name} is user - #{user}"
```

### Datastore

Unlike the brain, the datastore's getter and setter methods are asynchronous and don't resolve until the call to the underlying database has resolved. This requires a slightly different approach to accessing data:

```coffeescript
robot.respond /have a soda/i, (res) ->
# Get number of sodas had (coerced to a number).
robot.datastore.get('totalSodas').then (value) ->
sodasHad = value * 1 or 0

if sodasHad > 4
res.reply "I'm too fizzy.."
else
res.reply 'Sure!'
robot.brain.set 'totalSodas', sodasHad + 1

robot.respond /sleep it off/i, (res) ->
robot.datastore.set('totalSodas', 0).then () ->
res.reply 'zzzzz'
```

The datastore also allows setting and getting values which are scoped to individual users:

```coffeescript
module.exports = (robot) ->

robot.respond /who is @?([\w .\-]+)\?*$/i, (res) ->
name = res.match[1].trim()

users = robot.brain.usersForFuzzyName(name)
if users.length is 1
user = users[0]
user.get('roles').then (roles) ->
res.send "#{name} is #{roles.join(', ')}"
```

## Script Loading

There are three main sources to load scripts from:
Expand Down
3 changes: 3 additions & 0 deletions es2015.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const Adapter = require('./src/adapter')
const Response = require('./src/response')
const Listener = require('./src/listener')
const Message = require('./src/message')
const DataStore = require('./src/datastore')

module.exports = {
User,
Expand All @@ -22,6 +23,8 @@ module.exports = {
LeaveMessage: Message.LeaveMessage,
TopicMessage: Message.TopicMessage,
CatchAllMessage: Message.CatchAllMessage,
DataStore: DataStore.DataStore,
DataStoreUnavailable: DataStore.DataStoreUnavailable,

loadBot (adapterPath, adapterName, enableHttpd, botName, botAlias) {
return new module.exports.Robot(adapterPath, adapterName, enableHttpd, botName, botAlias)
Expand Down
12 changes: 10 additions & 2 deletions src/brain.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const User = require('./user')
// 2. If the original object was a User object, the original object
// 3. If the original object was a plain JavaScript object, return
// a User object with all of the original object's properties.
let reconstructUserIfNecessary = function (user) {
let reconstructUserIfNecessary = function (user, robot) {
if (!user) {
return null
}
Expand All @@ -20,6 +20,9 @@ let reconstructUserIfNecessary = function (user) {
delete user.id
// Use the old user as the "options" object,
// populating the new user with its values.
// Also add the `robot` field so it gets a reference.
user.robot = robot

return new User(id, user)
} else {
return user
Expand All @@ -36,6 +39,7 @@ class Brain extends EventEmitter {
users: {},
_private: {}
}
this.robot = robot

this.autoSave = true

Expand Down Expand Up @@ -142,7 +146,7 @@ class Brain extends EventEmitter {
if (data && data.users) {
for (let k in data.users) {
let user = this.data.users[k]
this.data.users[k] = reconstructUserIfNecessary(user)
this.data.users[k] = reconstructUserIfNecessary(user, this.robot)
}
}

Expand All @@ -161,6 +165,10 @@ class Brain extends EventEmitter {
// Returns a User instance of the specified user.
userForId (id, options) {
let user = this.data.users[id]
if (!options) {
options = {}
}
options.robot = this.robot

if (!user) {
user = new User(id, options)
Expand Down
94 changes: 94 additions & 0 deletions src/datastore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use strict'

class DataStore {
// Represents a persistent, database-backed storage for the robot. Extend this.
//
// Returns a new Datastore with no storage.
constructor (robot) {
this.robot = robot
}

// Public: Set value for key in the database. Overwrites existing
// values if present. Returns a promise which resolves when the
// write has completed.
//
// Value can be any JSON-serializable type.
set (key, value) {
return this._set(key, value, 'global')
}

// Public: Assuming `key` represents an object in the database,
// sets its `objectKey` to `value`. If `key` isn't already
// present, it's instantiated as an empty object.
setObject (key, objectKey, value) {
return this.get(key).then((object) => {
let target = object || {}
target[objectKey] = value
return this.set(key, target)
})
}

// Public: Adds the supplied value(s) to the end of the existing
// array in the database marked by `key`. If `key` isn't already
// present, it's instantiated as an empty array.
setArray (key, value) {
return this.get(key).then((object) => {
let target = object || []
// Extend the array if the value is also an array, otherwise
// push the single value on the end.
if (Array.isArray(value)) {
return this.set(key, target.push.apply(target, value))
} else {
return this.set(key, target.concat(value))
}
})
}

// Public: Get value by key if in the database or return `undefined`
// if not found. Returns a promise which resolves to the
// requested value.
get (key) {
return this._get(key, 'global')
}

// Public: Digs inside the object at `key` for a key named
// `objectKey`. If `key` isn't already present, or if it doesn't
// contain an `objectKey`, returns `undefined`.
getObject (key, objectKey) {
return this.get(key).then((object) => {
let target = object || {}
return target[objectKey]
})
}

// Private: Implements the underlying `set` logic for the datastore.
// This will be called by the public methods. This is one of two
// methods that must be implemented by subclasses of this class.
// `table` represents a unique namespace for this key, such as a
// table in a SQL database.
//
// This returns a resolved promise when the `set` operation is
// successful, and a rejected promise if the operation fails.
_set (key, value, table) {
return Promise.reject(new DataStoreUnavailable('Setter called on the abstract class.'))
}

// Private: Implements the underlying `get` logic for the datastore.
// This will be called by the public methods. This is one of two
// methods that must be implemented by subclasses of this class.
// `table` represents a unique namespace for this key, such as a
// table in a SQL database.
//
// This returns a resolved promise containing the fetched value on
// success, and a rejected promise if the operation fails.
_get (key, table) {
return Promise.reject(new DataStoreUnavailable('Getter called on the abstract class.'))
}
}

class DataStoreUnavailable extends Error {}

module.exports = {
DataStore,
DataStoreUnavailable
}
23 changes: 23 additions & 0 deletions src/datastores/memory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use strict'

const DataStore = require('../datastore').DataStore

class InMemoryDataStore extends DataStore {
constructor (robot) {
super(robot)
this.data = {
global: {},
users: {}
}
}

_get (key, table) {
return Promise.resolve(this.data[table][key])
}

_set (key, value, table) {
return Promise.resolve(this.data[table][key] = value)
}
}

module.exports = InMemoryDataStore
1 change: 1 addition & 0 deletions src/robot.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class Robot {
this.brain = new Brain(this)
this.alias = alias
this.adapter = null
this.datastore = null
this.Response = Response
this.commands = []
this.listeners = []
Expand Down
40 changes: 40 additions & 0 deletions src/user.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict'

const DataStoreUnavailable = require('./datastore').DataStoreUnavailable

class User {
// Represents a participating user in the chat.
//
Expand All @@ -12,6 +14,17 @@ class User {
options = {}
}

// Define a getter method so we don't actually store the
// robot itself on the user object, preventing it from
// being serialized into the brain.
if (options.robot) {
let robot = options.robot
delete options.robot
this._getRobot = function () { return robot }
} else {
this._getRobot = function () { }
}

Object.keys(options).forEach((key) => {
this[key] = options[key]
})
Expand All @@ -20,6 +33,33 @@ class User {
this.name = this.id.toString()
}
}

set (key, value) {
this._checkDatastoreAvailable()
return this._getDatastore()._set(this._constructKey(key), value, 'users')
}

get (key) {
this._checkDatastoreAvailable()
return this._getDatastore()._get(this._constructKey(key), 'users')
}

_constructKey (key) {
return `${this.id}+${key}`
}

_checkDatastoreAvailable () {
if (!this._getDatastore()) {
throw new DataStoreUnavailable('datastore is not initialized')
}
}

_getDatastore () {
let robot = this._getRobot()
if (robot) {
return robot.datastore
}
}
}

module.exports = User
Loading

0 comments on commit d2b57bd

Please sign in to comment.