Skip to content

Commit

Permalink
feat(device): automaticaly remove inactive devices
Browse files Browse the repository at this point in the history
  • Loading branch information
ncarlier committed Jan 18, 2022
1 parent 11f0501 commit e00d697
Show file tree
Hide file tree
Showing 11 changed files with 128 additions and 15 deletions.
3 changes: 3 additions & 0 deletions autogen/db/postgres/db_sql_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ ALTER TYPE article_status ADD VALUE 'to_read' AFTER 'read';
`,
"db_migration_11": `create type notification_strategy_type as enum('none', 'individual', 'global');
alter table categories add column notification_strategy notification_strategy_type not null default 'none';
`,
"db_migration_12": `alter table devices add column last_seen_at timestamp with time zone not null default now();
`,
"db_migration_2": `create table devices (
id serial not null,
Expand Down Expand Up @@ -164,6 +166,7 @@ var DatabaseSQLMigrationChecksums = map[string]string{
"db_migration_1": "6b7ac5c1474bc400c1bbb642fcf3c161f51de7252350eaa261cb1ed796e72b67",
"db_migration_10": "935f7f7208d0230865d0915bf8f6b940331084d3aeb951536605f879a85a842f",
"db_migration_11": "1150b8fa81099eb5956989560e8eebecafe5e39cbd1a5f6f7d23f3dfceb810bf",
"db_migration_12": "b24497bb03f04fb4705ae752f8a5bf69dad26f168bc8ec196af93aee29deef49",
"db_migration_2": "0be0d1ef1e9481d61db425a7d54378f3667c091949525b9c285b18660b6e8a1d",
"db_migration_3": "5cd0d3628d990556c0b85739fd376c42244da7e98b66852b6411d27eda20c3fc",
"db_migration_4": "d5fb83c15b523f15291310ff27d36c099c4ba68de2fd901c5ef5b70a18fedf65",
Expand Down
7 changes: 6 additions & 1 deletion pkg/db/device.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package db

import "github.com/ncarlier/readflow/pkg/model"
import (
"time"

"github.com/ncarlier/readflow/pkg/model"
)

// DeviceRepository is the repository interface to manage Devices
type DeviceRepository interface {
Expand All @@ -11,4 +15,5 @@ type DeviceRepository interface {
CreateDevice(device model.Device) (*model.Device, error)
DeleteDevice(id uint) error
DeleteDevicesByUser(uid uint, ids []uint) (int64, error)
DeleteInactiveDevicesOlderThan(delay time.Duration) (int64, error)
}
31 changes: 29 additions & 2 deletions pkg/db/postgres/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"strings"
"time"

sq "github.com/Masterminds/squirrel"
"github.com/ncarlier/readflow/pkg/model"
Expand All @@ -14,6 +15,7 @@ var deviceColumns = []string{
"user_id",
"key",
"subscription",
"last_seen_at",
"created_at",
}

Expand All @@ -27,6 +29,7 @@ func mapRowToDevice(row *sql.Row) (*model.Device, error) {
&device.UserID,
&device.Key,
&sub,
&device.LastSeenAt,
&device.CreatedAt,
)
if err == sql.ErrNoRows {
Expand All @@ -53,11 +56,12 @@ func (pg *DB) CreateDevice(device model.Device) (*model.Device, error) {
query, args, _ := pg.psql.Insert(
"devices",
).Columns(
"user_id", "key", "subscription",
"user_id", "key", "subscription", "last_seen_at",
).Values(
device.UserID,
device.Key,
sub,
"NOW()",
).Suffix(
"RETURNING " + strings.Join(deviceColumns, ","),
).ToSql()
Expand All @@ -68,16 +72,22 @@ func (pg *DB) CreateDevice(device model.Device) (*model.Device, error) {

// GetDeviceByID get a device from the DB
func (pg *DB) GetDeviceByID(id uint) (*model.Device, error) {
query, args, _ := pg.psql.Select(deviceColumns...).From(
// Update last seen attribute then return the device
query, args, _ := pg.psql.Update(
"devices",
).Set(
"last_seen_at", "now()",
).Where(
sq.Eq{"id": id},
).Suffix(
"RETURNING " + strings.Join(deviceColumns, ","),
).ToSql()
row := pg.db.QueryRow(query, args...)
return mapRowToDevice(row)
}

// GetDeviceByUserAndKey get an device from the DB
// Only exposed for testing purpose!
func (pg *DB) GetDeviceByUserAndKey(uid uint, key string) (*model.Device, error) {
query, args, _ := pg.psql.Select(deviceColumns...).From(
"devices",
Expand Down Expand Up @@ -114,6 +124,7 @@ func (pg *DB) GetDevicesByUser(uid uint) ([]model.Device, error) {
&device.UserID,
&device.Key,
&sub,
&device.LastSeenAt,
&device.CreatedAt,
)
if err != nil {
Expand Down Expand Up @@ -181,3 +192,19 @@ func (pg *DB) DeleteDevicesByUser(uid uint, ids []uint) (int64, error) {

return result.RowsAffected()
}

// DeleteInactiveDevicesOlderThan remove inactive devices from the DB
func (pg *DB) DeleteInactiveDevicesOlderThan(delay time.Duration) (int64, error) {
maxAge := time.Now().Add(-delay)
query, args, _ := pg.psql.Delete(
"devices",
).Where(
sq.Lt{"last_seen_at": maxAge},
).ToSql()

result, err := pg.db.Exec(query, args...)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
2 changes: 1 addition & 1 deletion pkg/db/postgres/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"github.com/rs/zerolog/log"
)

const schemaVersion = 11
const schemaVersion = 12

// Migrate executes database migrations.
func Migrate(db *sql.DB) {
Expand Down
1 change: 1 addition & 0 deletions pkg/db/postgres/sql/db_migration_12.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table devices add column last_seen_at timestamp with time zone not null default now();
66 changes: 58 additions & 8 deletions pkg/db/test/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package dbtest
import (
"encoding/json"
"testing"
"time"

webpush "github.com/SherClockHolmes/webpush-go"
"github.com/stretchr/testify/assert"
Expand All @@ -20,6 +21,15 @@ func newTestSubscription() webpush.Subscription {
}
}

func newTestDevice(t *testing.T, uid uint) *model.Device {
sub := newTestSubscription()
b, err := json.Marshal(sub)
assert.Nil(t, err)

builder := model.NewDeviceBuilder()
return builder.UserID(uid).Subscription(string(b)).Build()
}

func assertDeviceExists(t *testing.T, device model.Device) *model.Device {
result, err := testDB.GetDeviceByUserAndKey(*device.UserID, device.Key)
assert.Nil(t, err)
Expand All @@ -34,6 +44,7 @@ func assertDeviceExists(t *testing.T, device model.Device) *model.Device {
assert.Equal(t, *device.UserID, *result.UserID)
assert.NotEqual(t, "", result.Key)
assert.Equal(t, device.Key, result.Key)
assert.True(t, time.Now().After(*result.LastSeenAt))
return result
}

Expand All @@ -42,25 +53,64 @@ func TestCreateDevice(t *testing.T) {
defer teardownTestCase(t)

uid := *testUser.ID
sub := newTestSubscription()
b, err := json.Marshal(sub)
device := newTestDevice(t, uid)

// Create device
newDevice := assertDeviceExists(t, *device)

// Get device
newDevice, err := testDB.GetDeviceByID(*newDevice.ID)
assert.Nil(t, err)
assert.Equal(t, device.Key, newDevice.Key)

builder := model.NewDeviceBuilder()
device := builder.UserID(uid).Subscription(string(b)).Build()
// Delete the device
err = testDB.DeleteDevice(*newDevice.ID)
assert.Nil(t, err)

// Try to get the device again
device, err = testDB.GetDeviceByID(*newDevice.ID)
assert.Nil(t, err)
assert.Nil(t, device)
}

func TestListDevice(t *testing.T) {
teardownTestCase := setupTestCase(t)
defer teardownTestCase(t)

uid := *testUser.ID
device := newTestDevice(t, uid)

// Create device
newDevice := assertDeviceExists(t, *device)

// List devices
devices, err := testDB.GetDevicesByUser(uid)
assert.Nil(t, err)
assert.Positive(t, len(devices), "devices should not be empty")

// Cleanup
err = testDB.DeleteDevice(*newDevice.ID)
// Delete the device
deleted, err := testDB.DeleteDevicesByUser(uid, []uint{*newDevice.ID})
assert.Nil(t, err)
assert.Positive(t, deleted)
}

device, err = testDB.GetDeviceByUserAndKey(uid, newDevice.Key)
func TestDeviceCleanup(t *testing.T) {
teardownTestCase := setupTestCase(t)
defer teardownTestCase(t)

uid := *testUser.ID
device := newTestDevice(t, uid)

// Create device
assertDeviceExists(t, *device)

// Count devices
nb, err := testDB.CountDevicesByUser(uid)
assert.Nil(t, err)
assert.Nil(t, device)
assert.Positive(t, nb)

// Cleanup
deleted, err := testDB.DeleteInactiveDevicesOlderThan(0)
assert.Nil(t, err)
assert.Positive(t, deleted)
}
15 changes: 13 additions & 2 deletions pkg/job/clean-db.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import (
"github.com/rs/zerolog/log"
)

const (
maximumArticleRetentionDuration = 48 * time.Hour
maximumDeviceInactivityDuration = 30 * 24 * time.Hour
)

// CleanDatabaseJob is a job to clean the database
type CleanDatabaseJob struct {
db db.DB
Expand All @@ -31,12 +36,18 @@ func (cdj *CleanDatabaseJob) start() {
cdj.logger.Debug().Msg("job started")
for range cdj.ticker.C {
cdj.logger.Debug().Msg("running job...")
nb, err := cdj.db.DeleteReadArticlesOlderThan(48 * time.Hour)
nb, err := cdj.db.DeleteReadArticlesOlderThan(maximumArticleRetentionDuration)
if err != nil {
cdj.logger.Error().Err(err).Msg("unable to clean the database")
cdj.logger.Error().Err(err).Msg("unable to clean old articles from the database")
break
}
cdj.logger.Info().Int64("removed_articles", nb).Msg("cleanup done")
nb, err = cdj.db.DeleteInactiveDevicesOlderThan(maximumDeviceInactivityDuration)
if err != nil {
cdj.logger.Error().Err(err).Msg("unable to clean old devices from the database")
break
}
cdj.logger.Info().Int64("removed_devices", nb).Msg("cleanup done")
}
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/model/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ type DeviceNotification struct {
}

// Device structure definition
// Device key is a hash of the subscription payload and is used to prevent subscription duplication
type Device struct {
ID *uint `json:"id,omitempty"`
UserID *uint `json:"user_id,omitempty"`
Key string `json:"key,omitempty"`
Subscription *webpush.Subscription `json:"_"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}

Expand Down
11 changes: 11 additions & 0 deletions pkg/schema/device/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ import (
"github.com/ncarlier/readflow/pkg/service"
)

var devicesQueryField = &graphql.Field{
Type: graphql.NewList(deviceType),
Resolve: DevicesResolver,
}

// DevicesResolver is the resolver for retrieve devices
func DevicesResolver(p graphql.ResolveParams) (interface{}, error) {
return service.Lookup().GetDevices(p.Context)
}

var deviceQueryField = &graphql.Field{
Type: deviceType,
Args: graphql.FieldConfigArgument{
Expand All @@ -28,5 +38,6 @@ func deviceResolver(p graphql.ResolveParams) (interface{}, error) {
}

func init() {
schema.AddQueryField("devices", devicesQueryField)
schema.AddQueryField("device", deviceQueryField)
}
3 changes: 3 additions & 0 deletions pkg/schema/device/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ var deviceType = graphql.NewObject(
"key": &graphql.Field{
Type: graphql.String,
},
"last_seen_at": &graphql.Field{
Type: graphql.DateTime,
},
"created_at": &graphql.Field{
Type: graphql.DateTime,
},
Expand Down
2 changes: 1 addition & 1 deletion pkg/service/devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func (reg *Registry) NotifyDevices(ctx context.Context, msg string) (int, error)
}
if res.StatusCode == 410 {
// Registration is gone... we should remove the device
_, err = reg.DeleteDevice(ctx, *device.ID)
err = reg.db.DeleteDevice(*device.ID)
reg.logger.Info().Err(err).Uint(
"uid", uid,
).Uint("device", *device.ID).Msg("registration gone: device deleted")
Expand Down

0 comments on commit e00d697

Please sign in to comment.