mirror of
https://github.com/fiatjaf/khatru.git
synced 2025-03-28 18:51:52 +01:00
basic elasticsearch storage example
This commit is contained in:
parent
905a68cd91
commit
d306c03369
3
go.mod
3
go.mod
@ -5,6 +5,8 @@ go 1.18
|
|||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.8.0
|
github.com/PuerkitoBio/goquery v1.8.0
|
||||||
github.com/cockroachdb/pebble v0.0.0-20220723153705-3fc374e4dc66
|
github.com/cockroachdb/pebble v0.0.0-20220723153705-3fc374e4dc66
|
||||||
|
github.com/elastic/go-elasticsearch v0.0.0
|
||||||
|
github.com/elastic/go-elasticsearch/v8 v8.6.0
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/gorilla/websocket v1.4.2
|
github.com/gorilla/websocket v1.4.2
|
||||||
github.com/grokify/html-strip-tags-go v0.0.1
|
github.com/grokify/html-strip-tags-go v0.0.1
|
||||||
@ -50,6 +52,7 @@ require (
|
|||||||
github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect
|
github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||||
github.com/decred/dcrd/lru v1.0.0 // indirect
|
github.com/decred/dcrd/lru v1.0.0 // indirect
|
||||||
|
github.com/elastic/elastic-transport-go/v8 v8.0.0-20211216131617-bbee439d559c // indirect
|
||||||
github.com/go-errors/errors v1.0.1 // indirect
|
github.com/go-errors/errors v1.0.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
|
6
go.sum
6
go.sum
@ -123,6 +123,12 @@ github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4
|
|||||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||||
github.com/dvyukov/go-fuzz v0.0.0-20210602112143-b1f3d6f4ef4e h1:qTP1telKJHlToHlwPQNmVg4yfMDMHe4Z3SYmzkrvA2M=
|
github.com/dvyukov/go-fuzz v0.0.0-20210602112143-b1f3d6f4ef4e h1:qTP1telKJHlToHlwPQNmVg4yfMDMHe4Z3SYmzkrvA2M=
|
||||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||||
|
github.com/elastic/elastic-transport-go/v8 v8.0.0-20211216131617-bbee439d559c h1:onA2RpIyeCPvYAj1LFYiiMTrSpqVINWMfYFRS7lofJs=
|
||||||
|
github.com/elastic/elastic-transport-go/v8 v8.0.0-20211216131617-bbee439d559c/go.mod h1:87Tcz8IVNe6rVSLdBux1o/PEItLtyabHU3naC7IoqKI=
|
||||||
|
github.com/elastic/go-elasticsearch v0.0.0 h1:Pd5fqOuBxKxv83b0+xOAJDAkziWYwFinWnBO0y+TZaA=
|
||||||
|
github.com/elastic/go-elasticsearch v0.0.0/go.mod h1:TkBSJBuTyFdBnrNqoPc54FN0vKf5c04IdM4zuStJ7xg=
|
||||||
|
github.com/elastic/go-elasticsearch/v8 v8.6.0 h1:xMaSe8jIh7NHzmNo9YBkewmaD2Pr+tX+zLkXxhieny4=
|
||||||
|
github.com/elastic/go-elasticsearch/v8 v8.6.0/go.mod h1:Usvydt+x0dv9a1TzEUaovqbJor8rmOHy5dSmPeMAE2k=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
|
43
search/docker-compose.yml
Normal file
43
search/docker-compose.yml
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
|
||||||
|
# relay:
|
||||||
|
# build:
|
||||||
|
# context: ../
|
||||||
|
# dockerfile: ./basic/Dockerfile
|
||||||
|
# environment:
|
||||||
|
# PORT: 2700
|
||||||
|
# POSTGRESQL_DATABASE: postgres://nostr:nostr@postgres:5432/nostr?sslmode=disable
|
||||||
|
# depends_on:
|
||||||
|
# postgres:
|
||||||
|
# condition: service_healthy
|
||||||
|
# ports:
|
||||||
|
# - 2700:2700
|
||||||
|
# command: "./basic/relayer-basic"
|
||||||
|
|
||||||
|
elasticsearch:
|
||||||
|
container_name: elasticsearch
|
||||||
|
image: docker.elastic.co/elasticsearch/elasticsearch:8.6.0
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- network.host=0.0.0.0
|
||||||
|
- discovery.type=single-node
|
||||||
|
- cluster.name=docker-cluster
|
||||||
|
- node.name=cluster1-node1
|
||||||
|
- xpack.license.self_generated.type=basic
|
||||||
|
- xpack.security.enabled=false
|
||||||
|
- "ES_JAVA_OPTS=-Xms${ES_MEM:-4g} -Xmx${ES_MEM:-4g}"
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:9200:9200'
|
||||||
|
ulimits:
|
||||||
|
memlock:
|
||||||
|
soft: -1
|
||||||
|
hard: -1
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
["CMD-SHELL", "curl --silent --fail elasticsearch:9200/_cluster/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
|
65
search/main.go
Normal file
65
search/main.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/fiatjaf/relayer"
|
||||||
|
"github.com/fiatjaf/relayer/storage/elasticsearch"
|
||||||
|
"github.com/kelseyhightower/envconfig"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Relay struct {
|
||||||
|
storage *elasticsearch.ElasticsearchStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Relay) Name() string {
|
||||||
|
return "BasicRelay"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Relay) Storage() relayer.Storage {
|
||||||
|
return r.storage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Relay) OnInitialized(*relayer.Server) {}
|
||||||
|
|
||||||
|
func (r *Relay) Init() error {
|
||||||
|
err := envconfig.Process("", r)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't process envconfig: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Relay) AcceptEvent(evt *nostr.Event) bool {
|
||||||
|
// block events that are too large
|
||||||
|
jsonb, _ := json.Marshal(evt)
|
||||||
|
if len(jsonb) > 10000 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Relay) BeforeSave(evt *nostr.Event) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Relay) AfterSave(evt *nostr.Event) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := Relay{}
|
||||||
|
if err := envconfig.Process("", &r); err != nil {
|
||||||
|
log.Fatalf("failed to read from env: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.storage = &elasticsearch.ElasticsearchStorage{}
|
||||||
|
if err := relayer.Start(&r); err != nil {
|
||||||
|
log.Fatalf("server terminated: %v", err)
|
||||||
|
}
|
||||||
|
}
|
250
storage/elasticsearch/elasticsearch.go
Normal file
250
storage/elasticsearch/elasticsearch.go
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
package elasticsearch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/elastic/go-elasticsearch/esapi"
|
||||||
|
"github.com/elastic/go-elasticsearch/v8"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. create index with mapping in Init
|
||||||
|
2. implement delete
|
||||||
|
3. build query in QueryEvents
|
||||||
|
4. implement replaceable events
|
||||||
|
*/
|
||||||
|
|
||||||
|
var indexMapping = `
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"number_of_shards": 1,
|
||||||
|
"number_of_replicas": 0
|
||||||
|
},
|
||||||
|
"mappings": {
|
||||||
|
"dynamic": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "keyword"},
|
||||||
|
"pubkey": {"type": "keyword"},
|
||||||
|
"kind": {"type": "integer"},
|
||||||
|
"tags": {"type": "keyword"},
|
||||||
|
"created_at": {"type": "date"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
type ElasticsearchStorage struct {
|
||||||
|
es *elasticsearch.Client
|
||||||
|
indexName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ess *ElasticsearchStorage) Init() error {
|
||||||
|
es, err := elasticsearch.NewDefaultClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// log.Println(elasticsearch.Version)
|
||||||
|
// log.Println(es.Info())
|
||||||
|
|
||||||
|
// todo: config
|
||||||
|
ess.indexName = "test"
|
||||||
|
|
||||||
|
// todo: don't delete index every time
|
||||||
|
// es.Indices.Delete([]string{ess.indexName})
|
||||||
|
|
||||||
|
res, err := es.Indices.Create(ess.indexName, es.Indices.Create.WithBody(strings.NewReader(indexMapping)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res.IsError() {
|
||||||
|
body, _ := io.ReadAll(res.Body)
|
||||||
|
txt := string(body)
|
||||||
|
if !strings.Contains(txt, "resource_already_exists_exception") {
|
||||||
|
return fmt.Errorf("%s", txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ess.es = es
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type EsSearchResult struct {
|
||||||
|
Took int
|
||||||
|
TimedOut bool `json:"timed_out"`
|
||||||
|
Hits struct {
|
||||||
|
Total struct {
|
||||||
|
Value int
|
||||||
|
Relation string
|
||||||
|
}
|
||||||
|
Hits []struct {
|
||||||
|
Source nostr.Event `json:"_source"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDsl(filter *nostr.Filter) string {
|
||||||
|
b := &strings.Builder{}
|
||||||
|
b.WriteString(`{"query": {"bool": {"filter": {"bool": {"must": [`)
|
||||||
|
|
||||||
|
prefixFilter := func(fieldName string, values []string) {
|
||||||
|
b.WriteString(`{"bool": {"should": [`)
|
||||||
|
for idx, val := range values {
|
||||||
|
if idx > 0 {
|
||||||
|
b.WriteRune(',')
|
||||||
|
}
|
||||||
|
op := "term"
|
||||||
|
if len(val) < 64 {
|
||||||
|
op = "prefix"
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf(`{"%s": {"%s": %q}}`, op, fieldName, val))
|
||||||
|
}
|
||||||
|
b.WriteString(`]}},`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ids
|
||||||
|
prefixFilter("id", filter.IDs)
|
||||||
|
|
||||||
|
// authors
|
||||||
|
prefixFilter("pubkey", filter.Authors)
|
||||||
|
|
||||||
|
// kinds
|
||||||
|
if len(filter.Kinds) > 0 {
|
||||||
|
k, _ := json.Marshal(filter.Kinds)
|
||||||
|
b.WriteString(fmt.Sprintf(`{"terms": {"kind": %s}},`, k))
|
||||||
|
}
|
||||||
|
|
||||||
|
// tags
|
||||||
|
{
|
||||||
|
b.WriteString(`{"bool": {"should": [`)
|
||||||
|
commaIdx := 0
|
||||||
|
for char, terms := range filter.Tags {
|
||||||
|
if len(terms) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if commaIdx > 0 {
|
||||||
|
b.WriteRune(',')
|
||||||
|
}
|
||||||
|
commaIdx++
|
||||||
|
b.WriteString(`{"bool": {"must": [`)
|
||||||
|
for _, t := range terms {
|
||||||
|
b.WriteString(fmt.Sprintf(`{"term": {"tags": %q}},`, t))
|
||||||
|
}
|
||||||
|
// add the tag type at the end
|
||||||
|
b.WriteString(fmt.Sprintf(`{"term": {"tags": %q}}`, char))
|
||||||
|
b.WriteString(`]}}`)
|
||||||
|
}
|
||||||
|
b.WriteString(`]}},`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// since
|
||||||
|
if filter.Since != nil {
|
||||||
|
b.WriteString(fmt.Sprintf(`{"range": {"created_at": {"gt": %d}}},`, filter.Since.Unix()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// until
|
||||||
|
if filter.Until != nil {
|
||||||
|
b.WriteString(fmt.Sprintf(`{"range": {"created_at": {"lt": %d}}},`, filter.Until.Unix()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// all blocks have a trailing comma...
|
||||||
|
// add a match_all "noop" at the end
|
||||||
|
// so json is valid
|
||||||
|
b.WriteString(`{"match_all": {}}`)
|
||||||
|
b.WriteString(`]}}}}}`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ess *ElasticsearchStorage) QueryEvents(filter *nostr.Filter) ([]nostr.Event, error) {
|
||||||
|
// Perform the search request...
|
||||||
|
// need to build up query body...
|
||||||
|
if filter == nil {
|
||||||
|
return nil, errors.New("filter cannot be null")
|
||||||
|
}
|
||||||
|
|
||||||
|
dsl := buildDsl(filter)
|
||||||
|
// pprint([]byte(dsl))
|
||||||
|
|
||||||
|
es := ess.es
|
||||||
|
res, err := es.Search(
|
||||||
|
es.Search.WithContext(context.Background()),
|
||||||
|
es.Search.WithIndex(ess.indexName),
|
||||||
|
|
||||||
|
es.Search.WithBody(strings.NewReader(dsl)),
|
||||||
|
es.Search.WithSize(filter.Limit),
|
||||||
|
es.Search.WithSort("created_at:desc"),
|
||||||
|
|
||||||
|
es.Search.WithTrackTotalHits(true),
|
||||||
|
es.Search.WithPretty(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error getting response: %s", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.IsError() {
|
||||||
|
txt, _ := io.ReadAll(res.Body)
|
||||||
|
fmt.Println("oh no", string(txt))
|
||||||
|
return nil, fmt.Errorf("%s", txt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var r EsSearchResult
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
events := make([]nostr.Event, len(r.Hits.Hits))
|
||||||
|
for i, e := range r.Hits.Hits {
|
||||||
|
events[i] = e.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ess *ElasticsearchStorage) DeleteEvent(id string, pubkey string) error {
|
||||||
|
// todo: is pubkey match required?
|
||||||
|
res, err := ess.es.Delete(ess.indexName, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res.IsError() {
|
||||||
|
txt, _ := io.ReadAll(res.Body)
|
||||||
|
return fmt.Errorf("%s", txt)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ess *ElasticsearchStorage) SaveEvent(event *nostr.Event) error {
|
||||||
|
data, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req := esapi.IndexRequest{
|
||||||
|
Index: ess.indexName,
|
||||||
|
DocumentID: event.ID,
|
||||||
|
Body: bytes.NewReader(data),
|
||||||
|
// Refresh: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = req.Do(context.Background(), ess.es)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func pprint(j []byte) {
|
||||||
|
var dst bytes.Buffer
|
||||||
|
err := json.Indent(&dst, j, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("invalid JSON", err, string(j))
|
||||||
|
} else {
|
||||||
|
fmt.Println(dst.String())
|
||||||
|
}
|
||||||
|
}
|
46
storage/elasticsearch/query_test.go
Normal file
46
storage/elasticsearch/query_test.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package elasticsearch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQuery(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
yesterday := now.Add(time.Hour * -24)
|
||||||
|
filter := &nostr.Filter{
|
||||||
|
// IDs: []string{"abc", "123", "971b9489b4fd4e41a85951607922b982d981fa9d55318bc304f21f390721404c"},
|
||||||
|
Kinds: []int{0, 1},
|
||||||
|
// Tags: nostr.TagMap{
|
||||||
|
// "a": []string{"abc"},
|
||||||
|
// "b": []string{"aaa", "bbb"},
|
||||||
|
// },
|
||||||
|
Since: &yesterday,
|
||||||
|
Until: &now,
|
||||||
|
Limit: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
dsl := buildDsl(filter)
|
||||||
|
pprint([]byte(dsl))
|
||||||
|
|
||||||
|
if !json.Valid([]byte(dsl)) {
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
|
||||||
|
// "integration" test
|
||||||
|
ess := &ElasticsearchStorage{}
|
||||||
|
err := ess.Init()
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := ess.QueryEvents(filter)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
fmt.Println(found)
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user