mirror of
https://github.com/fiatjaf/khatru.git
synced 2025-03-17 21:32:55 +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 (
|
||||
github.com/PuerkitoBio/goquery v1.8.0
|
||||
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/websocket v1.4.2
|
||||
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/dcrec/secp256k1/v4 v4.0.1 // 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/gogo/protobuf v1.3.2 // 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/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/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.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
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