Skip to content

Commit 9e11fb5

Browse files
Transactional outbox and event audit records (#18)
* Update README 📚 * Event entity * Create event when we create a Todo * Refactor nuke function * Create separate field for todo_id * Create event when we delete a Todo * Use xid rather than Dgraph uid in event table * Use field constants * Events store xid not Dgraph uid * Fix event type and Dgraph type
1 parent 6f2565a commit 9e11fb5

File tree

10 files changed

+175
-48
lines changed

10 files changed

+175
-48
lines changed

README.md

+43-10
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,54 @@
11
![go-dgraph-starter](https://raw.githubusercontent.com/graph-gophers/graphql-go/master/docs/img/logo.png)
22

3+
## Table of Contents
4+
1. [Introduction](#introduction)
5+
1. [In Progress](#in-progress)
6+
1. [Tools](#tools)
7+
1. [Protocol Buffers](#protocol-buffers)
8+
1. [gRPC](#grpc)
9+
1. [GraphQL](#graphql)
10+
1. [Dgraph](#dgraph)
11+
1. [Sqlboiler](#sqlboiler)
12+
1. [Meilisearch](#meilisearch)
13+
1. [Patterns](#patterns)
14+
1. [Cursor pagination](#cursor-pagination)
15+
1. [Garden](#garden)
16+
1. [Change Data Capture](#change-data-capture)
17+
18+
TOC generated with
19+
```
20+
docker run -v $PWD:/app -w /app --rm -it pbzweihander/markdown-toc README.md --min-depth 1
21+
```
22+
323
## Introduction
424

525
[![forthebadge](https://forthebadge.com/images/badges/60-percent-of-the-time-works-every-time.svg)](https://forthebadge.com)
626

727
This is a Todo List app built with [Dgraph](https://dgraph.io/)-backed [gRPC](https://grpc.io/) and [GraphQL](https://graphql.org/) APIs, combined with a [NextJS](https://nextjs.org/) + [Chakra-UI](https://chakra-ui.com/) powered front-end.
828

929
### In Progress
30+
1031
This project is still very much in progress.
1132

1233
https://github.com/kevinmichaelchen/go-dgraph-starter/projects/1
1334

1435
### Tools
36+
1537
#### Protocol Buffers
16-
Protocol buffers are a great choice for a language-neutral representation
38+
39+
Protocol buffers are a great choice for a language-neutral representation
1740
of your data models.
1841

1942
They are binary, lean, and fast to serialize when compared with JSON.
2043

21-
Their extensibility comes from the fact that you can
44+
Their extensibility comes from the fact that you can
2245
add or remove fields in a way while maintaining backwards compatibilty.
2346

2447
Code generation makes it easy to generate your models in any language.
2548

2649
#### gRPC
27-
gRPC is a lean transport system (typically paired with HTTP/2) meant to
50+
51+
gRPC is a lean transport system (typically paired with HTTP/2) meant to
2852
reduce latency and payload size.
2953

3054
HTTP/2 allows for long-lived connections (fewer handshakes).
@@ -34,11 +58,13 @@ Unlike REST, gRPC uses an HTTP POST method for all calls, and doesn't specify th
3458
gRPC is also binary, so it's not as easy to debug than something human-readable, like GraphQL. But it is excellent for internal calls between microservices.
3559

3660
#### GraphQL
61+
3762
GraphQL is an ideal query language for APIs, especially when consumed by web clients. It's typically human-readable (JSON). Clients can specify exactly which fields they want. GraphiQL is an amazing tool for gaining insight into what an API offers.
3863

3964
It also can make you think about your data as a graph.
4065

4166
#### Dgraph
67+
4268
Dgraph is a popular open-source, fast, distributed graph database written in Golang.
4369
You get high throughput and low latency for deep joins and complex traversals.
4470
It offers a query language that is a superset of GraphQL.
@@ -50,25 +76,31 @@ Performance: SQL performance suffers the more joins you ask it to do.
5076
Flexibility / Complexity: Like NoSQL, the schema can be modified easily with time. Adding new relationships is as simple as adding a predicate. No need to create join tables. Ultimately, the graph model is more simpler / more intuitive.
5177

5278
#### Sqlboiler
53-
It's not currently used in this project, but it's worth mentioning [Sqlboiler](https://github.com/volatiletech/sqlboiler)
54-
since I believe it is by far the best SQL ORM for Golang due its "data-first"
55-
approach: it auto-generates Go code based on your existing schema and as a
79+
80+
It's not currently used in this project, but it's worth mentioning [Sqlboiler](https://github.com/volatiletech/sqlboiler)
81+
since I believe it is by far the best SQL ORM for Golang due its "data-first"
82+
approach: it auto-generates Go code based on your existing schema and as a
5683
result you get extreme type safety.
5784

5885
For services that aren't expected to have a whole lot of data relationships, SQL is still an excellent choice, and sqlboiler is an ideal ORM for keeping your persistence layer code strongly typed.
5986

6087
#### Meilisearch
88+
6189
We use [MeiliSearch](https://www.meilisearch.com/) as our "open source, blazingly fast and hyper relevant search-engine."
6290

6391
Per their site, MeiliSearch is effective and accessible:
64-
> Efficient search engines are often only accessible to companies with the financial means and resources necessary to develop a search solution adapted to their needs. The majority of other companies that do not have the means or do not realize that the lack of relevance of a search greatly impacts the pleasure of navigation on their application, end up with poor solutions that are more frustrating than effective, for both the developer and the user.
92+
93+
> Efficient search engines are often only accessible to companies with the financial means and resources necessary to develop a search solution adapted to their needs. The majority of other companies that do not have the means or do not realize that the lack of relevance of a search greatly impacts the pleasure of navigation on their application, end up with poor solutions that are more frustrating than effective, for both the developer and the user.
6594
6695
### Patterns
96+
6797
#### Cursor pagination
68-
* https://uxdesign.cc/why-facebook-says-cursor-pagination-is-the-greatest-d6b98d86b6c0
69-
* https://relay.dev/graphql/connections.htm
98+
99+
- https://uxdesign.cc/why-facebook-says-cursor-pagination-is-the-greatest-d6b98d86b6c0
100+
- https://relay.dev/graphql/connections.htm
70101

71102
#### Garden
103+
72104
The problem: developers will not bother with running CI tests and integration tests locally; instead they'll push to their GitHub PR and let the CI system take care of it.
73105

74106
Not only are they deprived of a production-like system on their local machines, but the feedback loop is too slow: having CircleCI run all the end-to-end tests takes too long.
@@ -78,4 +110,5 @@ Not only are they deprived of a production-like system on their local machines,
78110
For shops with hundreds or thousands of microservices, I can see the argument that it's excessive (or even impossible) to run the whole system on your local machine. That said, maybe there's a term for a subset of services that are related, and maybe it's worth running those on Garden.
79111

80112
#### Change Data Capture
81-
In a distributed system, when events occur in one service, they need to eventually be broadcast to other services. Too often, I've seen an event get emitted inside a database transaction, regardless of whether that transaction succeeds or fails. The [transactional outbox](https://microservices.io/patterns/data/transactional-outbox.html) pattern offers a way to keep data across services in sync.
113+
114+
In a distributed system, when events occur in one service, they need to eventually be broadcast to other services. Too often, I've seen an event get emitted inside a database transaction, regardless of whether that transaction succeeds or fails. The [transactional outbox](https://microservices.io/patterns/data/transactional-outbox.html) pattern offers a way to keep data across services in sync.

api/internal/app/app.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func (a App) Run() {
3838

3939
// Drop all data and schema
4040
log.Info().Msg("Dropping all Dgraph data...")
41-
if err := db.Nuke(context.Background(), dgraphClient); err != nil {
41+
if err := db.NukeDataAndSchema(context.Background(), dgraphClient); err != nil {
4242
log.Fatal().Err(err).Msg("failed to nuke database")
4343
}
4444

api/internal/db/nuke.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ import (
77
"github.com/dgraph-io/dgo/v200/protos/api"
88
)
99

10-
func Nuke(ctx context.Context, dgraphClient *dgo.Dgraph) error {
10+
func NukeDataAndSchema(ctx context.Context, dgraphClient *dgo.Dgraph) error {
1111
return dgraphClient.Alter(ctx, &api.Operation{
1212
DropAll: true,
1313
})
1414
}
1515

16-
func NukeData(ctx context.Context, dgraphClient *dgo.Dgraph) error {
16+
func NukeDataButNotSchema(ctx context.Context, dgraphClient *dgo.Dgraph) error {
1717
return dgraphClient.Alter(ctx, &api.Operation{
1818
DropOp: api.Operation_DATA,
1919
})

api/internal/db/schema.go

+17
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,23 @@ const schema = `
2828
name
2929
created_at
3030
}
31+
32+
event_type: string @index(exact) .
33+
event_at: datetime @index(hour) .
34+
todo_id: string @index(exact) .
35+
is_published_to_search_index: bool .
36+
creator_id: string @index(exact) .
37+
38+
type TodoEvent {
39+
event_type
40+
event_at
41+
is_published_to_search_index
42+
todo_id
43+
created_at
44+
title
45+
is_done
46+
creator_id
47+
}
3148
`
3249

3350
func BuildSchema(ctx context.Context, dgraphClient *dgo.Dgraph) error {

api/internal/db/transaction.go

+26
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,32 @@ import (
1010
"github.com/MyOrg/go-dgraph-starter/internal/configuration"
1111
)
1212

13+
const (
14+
eventTypeCreate = "create"
15+
eventTypeUpdate = "update"
16+
eventTypeDelete = "delete"
17+
18+
dgraphTypeUser = "User"
19+
dgraphTypeTodo = "Todo"
20+
dgraphTypeTodoEvent = "TodoEvent"
21+
22+
fieldDgraphType = "dgraph.type"
23+
24+
fieldID = "id"
25+
fieldCreatedAt = "created_at"
26+
fieldCreator = "creator"
27+
28+
fieldEventAt = "event_at"
29+
fieldEventType = "event_type"
30+
fieldTodoID = "todo_id"
31+
fieldEventPublishedToSearchIndex = "is_published_to_search_index"
32+
33+
fieldName = "name"
34+
fieldTitle = "title"
35+
fieldDone = "is_done"
36+
fieldCreatorID = "creator_id"
37+
)
38+
1339
type Transaction interface {
1440
TodoTransaction
1541
}

api/internal/db/tx_todo_create.go

+21-10
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ func (tx *todoTransactionImpl) CreateTodo(ctx context.Context, item *todoV1.Todo
5454
// Insert user into database
5555
if res, err := tx.tx.Mutate(ctx, &api.Mutation{
5656
Set: []*api.NQuad{
57-
nquadStr("_:newUser", "dgraph.type", "User"),
58-
nquadStr("_:newUser", "id", item.CreatorId),
59-
nquadStr("_:newUser", "name", "Alice"),
60-
nquadStr("_:newUser", "created_at", nowStr),
57+
nquadStr("_:newUser", fieldDgraphType, dgraphTypeUser),
58+
nquadStr("_:newUser", fieldID, item.CreatorId),
59+
nquadStr("_:newUser", fieldName, "Alice"),
60+
nquadStr("_:newUser", fieldCreatedAt, nowStr),
6161
},
6262
}); err != nil {
6363
return err
@@ -74,15 +74,26 @@ func (tx *todoTransactionImpl) CreateTodo(ctx context.Context, item *todoV1.Todo
7474
// Insert into database
7575
res, err := tx.tx.Mutate(ctx, &api.Mutation{
7676
Set: []*api.NQuad{
77-
nquadStr("_:todo", "dgraph.type", "Todo"),
78-
nquadStr("_:todo", "id", item.Id),
79-
nquadStr("_:todo", "title", item.Title),
80-
nquadStr("_:todo", "created_at", nowStr),
81-
nquadBool("_:todo", "is_done", item.Done),
82-
nquadRel("_:todo", "creator", creatorUID),
77+
nquadStr("_:todo", fieldDgraphType, dgraphTypeTodo),
78+
nquadStr("_:todo", fieldID, item.Id),
79+
nquadStr("_:todo", fieldTitle, item.Title),
80+
nquadStr("_:todo", fieldCreatedAt, nowStr),
81+
nquadBool("_:todo", fieldDone, item.Done),
82+
nquadRel("_:todo", fieldCreator, creatorUID),
83+
84+
nquadStr("_:todoEvent", fieldDgraphType, dgraphTypeTodoEvent),
85+
nquadStr("_:todoEvent", fieldEventType, eventTypeCreate),
86+
nquadStr("_:todoEvent", fieldEventAt, nowStr),
87+
nquadBool("_:todoEvent", fieldEventPublishedToSearchIndex, false),
88+
nquadStr("_:todoEvent", fieldTodoID, item.Id),
89+
nquadStr("_:todoEvent", fieldTitle, item.Title),
90+
nquadStr("_:todoEvent", fieldCreatedAt, nowStr),
91+
nquadBool("_:todoEvent", fieldDone, item.Done),
92+
nquadStr("_:todoEvent", fieldCreatorID, item.CreatorId),
8393
},
8494
})
8595

96+
// Handle error
8697
if err != nil {
8798
return err
8899
}

api/internal/db/tx_todo_delete.go

+30-2
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,34 @@ package db
22

33
import (
44
"context"
5+
"time"
56

67
"github.com/MyOrg/go-dgraph-starter/internal/obs"
78
todoV1 "github.com/MyOrg/go-dgraph-starter/pkg/pb/myorg/todo/v1"
89
"github.com/dgraph-io/dgo/v200/protos/api"
910
)
1011

1112
func (tx *todoTransactionImpl) DeleteTodo(ctx context.Context, id string) (*todoV1.DeleteTodoResponse, error) {
12-
ctx, span := obs.NewSpan(ctx, "CreateTodo")
13+
ctx, span := obs.NewSpan(ctx, "DeleteTodo")
1314
defer span.End()
1415

1516
logger := obs.ToLogger(ctx)
1617

18+
nowStr := time.Now().UTC().Format(time.RFC3339)
19+
20+
// Perform deletion
1721
res, err := tx.tx.Do(ctx, &api.Request{
1822
Query: `
1923
query getTodo($id: string) {
20-
todo as var(func: eq(id, $id))
24+
todo as var(func: eq(id, $id)) {
25+
todo_id as id
26+
todo_created_at as created_at
27+
todo_title as title
28+
todo_is_done as is_done
29+
creator {
30+
todo_creator_id as id
31+
}
32+
}
2133
}
2234
`,
2335
Mutations: []*api.Mutation{
@@ -26,19 +38,35 @@ func (tx *todoTransactionImpl) DeleteTodo(ctx context.Context, id string) (*todo
2638
// Otherwise, we'll assume there are no Todos with that ID,
2739
// and we don't perform the Deletion, thus ensuring Idempotence.
2840
Cond: `@if(eq(len(todo), 1))`,
41+
42+
// Instruct Dgraph to delete the Todo
2943
Del: []*api.NQuad{
3044
// The pattern S * * deletes all the known edges out of a node,
3145
// any reverse edges corresponding to the removed edges,
3246
// and any indexing for the removed data.
3347
nquadAll("uid(todo)"),
3448
},
49+
50+
// Insert event
51+
Set: []*api.NQuad{
52+
nquadStr("_:todoEvent", fieldDgraphType, dgraphTypeTodoEvent),
53+
nquadStr("_:todoEvent", fieldEventType, eventTypeDelete),
54+
nquadStr("_:todoEvent", fieldEventAt, nowStr),
55+
nquadBool("_:todoEvent", fieldEventPublishedToSearchIndex, false),
56+
nquadRel("_:todoEvent", fieldTodoID, "val(todo_id)"),
57+
nquadRel("_:todoEvent", fieldTitle, "val(todo_title)"),
58+
nquadRel("_:todoEvent", fieldCreatedAt, "val(todo_created_at)"),
59+
nquadRel("_:todoEvent", fieldDone, "val(todo_is_done)"),
60+
nquadRel("_:todoEvent", fieldCreatorID, "val(todo_creator_id)"),
61+
},
3562
},
3663
},
3764
Vars: map[string]string{
3865
"$id": id,
3966
},
4067
})
4168

69+
// Handle error
4270
if err != nil {
4371
return nil, err
4472
}

0 commit comments

Comments
 (0)