up
This commit is contained in:
parent
4f291c93e5
commit
3892d966f3
72
client.go
72
client.go
@ -1,72 +0,0 @@
|
|||||||
package shortify
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ClientConfig struct {
|
|
||||||
UseUnixSocket bool // true = unix socket, false = http
|
|
||||||
SocketPath string // e.g. /tmp/shorty.sock
|
|
||||||
HTTPAddress string // e.g. http://localhost:8080
|
|
||||||
Timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
httpClient *http.Client
|
|
||||||
baseURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(cfg ClientConfig) (*Client, error) {
|
|
||||||
transport := &http.Transport{}
|
|
||||||
|
|
||||||
if cfg.UseUnixSocket {
|
|
||||||
dialer := func(_ context.Context, _, _ string) (net.Conn, error) {
|
|
||||||
return net.Dial("unix", cfg.SocketPath)
|
|
||||||
}
|
|
||||||
transport.DialContext = dialer
|
|
||||||
cfg.HTTPAddress = "http://unix" // dummy for request building
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Transport: transport,
|
|
||||||
Timeout: cfg.Timeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Client{
|
|
||||||
httpClient: client,
|
|
||||||
baseURL: cfg.HTTPAddress,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Shorten(url string) (string, error) {
|
|
||||||
body := []byte(url)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", c.baseURL+"/s", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
b, _ := io.ReadAll(resp.Body)
|
|
||||||
return "", errors.New("shorten failed: " + string(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(result), nil
|
|
||||||
}
|
|
@ -2,18 +2,30 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"git.tijl.dev/tijl/shortify"
|
"git.tijl.dev/tijl/shortify"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
s, err := shortify.NewShortener(shortify.Config{
|
s, err := shortify.New(shortify.Config{
|
||||||
DataFolder: "./shortify",
|
DataFolder: "./shoritfy",
|
||||||
CacheSize: 10000,
|
|
||||||
AccessLogSize: 10000,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go s.ServeSocket("./data/shortify.sock")
|
// listen the admin interface
|
||||||
s.ServeHTTP("0.0.0.0:3001")
|
|
||||||
|
unixListener, err := shortify.GetUnixListener("./shortify/admin.sock")
|
||||||
|
|
||||||
|
go s.Admin().Listener(unixListener)
|
||||||
|
|
||||||
|
// listen the interface for normies
|
||||||
|
|
||||||
|
a := fiber.New()
|
||||||
|
|
||||||
|
a.Get("/shorten", s.HandlePostURL())
|
||||||
|
a.Get("/s/:id", s.HandleGetURL())
|
||||||
|
|
||||||
|
a.Listen(":3001")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
218
generation.go
218
generation.go
@ -1,218 +0,0 @@
|
|||||||
package shortify
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"math/big"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.etcd.io/bbolt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type IDPool struct {
|
|
||||||
db *bbolt.DB
|
|
||||||
mu sync.Mutex
|
|
||||||
idLen int
|
|
||||||
poolCap int
|
|
||||||
regenThresh int
|
|
||||||
inMemoryPool []string
|
|
||||||
cond *sync.Cond
|
|
||||||
stopCh chan struct{}
|
|
||||||
usedChan chan string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewIDPool(db *bbolt.DB, idLen int, poolCap int, regenThresh int) (*IDPool, error) {
|
|
||||||
p := &IDPool{
|
|
||||||
db: db,
|
|
||||||
idLen: idLen,
|
|
||||||
poolCap: poolCap,
|
|
||||||
regenThresh: regenThresh,
|
|
||||||
stopCh: make(chan struct{}),
|
|
||||||
usedChan: make(chan string, 1000),
|
|
||||||
}
|
|
||||||
p.cond = sync.NewCond(&p.mu)
|
|
||||||
|
|
||||||
err := db.Update(func(tx *bbolt.Tx) error {
|
|
||||||
_, err := tx.CreateBucketIfNotExists(idpoolBucket)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := p.loadFromDB(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(p.inMemoryPool) == 0 {
|
|
||||||
// idpool empty at startup, generating initial batch...
|
|
||||||
if err := p.GenerateBatch(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := p.loadFromDB(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
go p.backgroundGenerator()
|
|
||||||
go p.flushUsedIDs()
|
|
||||||
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *IDPool) loadFromDB() error {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
|
|
||||||
err := p.db.View(func(tx *bbolt.Tx) error {
|
|
||||||
b := tx.Bucket(idpoolBucket)
|
|
||||||
if b == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
c := b.Cursor()
|
|
||||||
for k, _ := c.First(); k != nil; k, _ = c.Next() {
|
|
||||||
ids = append(ids, string(k))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
p.inMemoryPool = ids
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *IDPool) GenerateBatch() error {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
|
|
||||||
return p.db.Update(func(tx *bbolt.Tx) error {
|
|
||||||
b := tx.Bucket(idpoolBucket)
|
|
||||||
count := b.Stats().KeyN
|
|
||||||
for count < p.poolCap {
|
|
||||||
id, err := generateID(p.idLen)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if b.Get([]byte(id)) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := b.Put([]byte(id), []byte{}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PopID returns an ID from memory and queues it for async DB removal
|
|
||||||
func (p *IDPool) PopID() (string, error) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
|
|
||||||
if len(p.inMemoryPool) == 0 {
|
|
||||||
return "", errors.New("id pool empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fast O(1) pop
|
|
||||||
id := p.inMemoryPool[0]
|
|
||||||
p.inMemoryPool = p.inMemoryPool[1:]
|
|
||||||
|
|
||||||
// Queue for async delete
|
|
||||||
select {
|
|
||||||
case p.usedChan <- id:
|
|
||||||
default:
|
|
||||||
// If the channel is full, we drop the delete. Risky only if shutdown happens
|
|
||||||
log.Println("Warning: used ID queue full; delete may be delayed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal for batch regen if low
|
|
||||||
if len(p.inMemoryPool) < p.regenThresh {
|
|
||||||
p.cond.Signal()
|
|
||||||
}
|
|
||||||
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *IDPool) backgroundGenerator() {
|
|
||||||
for {
|
|
||||||
p.mu.Lock()
|
|
||||||
for len(p.inMemoryPool) >= p.regenThresh {
|
|
||||||
p.cond.Wait()
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-p.stopCh:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// generating batch
|
|
||||||
err := p.GenerateBatch()
|
|
||||||
if err != nil {
|
|
||||||
// error generating batch:(
|
|
||||||
time.Sleep(time.Second * 5)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := p.loadFromDB(); err != nil {
|
|
||||||
// error laoding from db:(
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *IDPool) flushUsedIDs() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-p.stopCh:
|
|
||||||
return
|
|
||||||
case id := <-p.usedChan:
|
|
||||||
err := p.db.Update(func(tx *bbolt.Tx) error {
|
|
||||||
b := tx.Bucket(idpoolBucket)
|
|
||||||
return b.Delete([]byte(id))
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to delete used ID %s: %v\n", id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *IDPool) Stop() {
|
|
||||||
close(p.stopCh)
|
|
||||||
// Drain and flush remaining used IDs
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case id := <-p.usedChan:
|
|
||||||
p.db.Update(func(tx *bbolt.Tx) error {
|
|
||||||
b := tx.Bucket(idpoolBucket)
|
|
||||||
return b.Delete([]byte(id))
|
|
||||||
})
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var idpoolBucket = []byte("idpool")
|
|
||||||
var base62 = []rune("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
|
||||||
|
|
||||||
func generateID(n int) (string, error) {
|
|
||||||
id := make([]rune, n)
|
|
||||||
for i := range id {
|
|
||||||
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(base62))))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
id[i] = base62[num.Int64()]
|
|
||||||
}
|
|
||||||
return string(id), nil
|
|
||||||
}
|
|
35
global.go
35
global.go
@ -1,35 +0,0 @@
|
|||||||
package shortify
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"go.etcd.io/bbolt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Global instance
|
|
||||||
var (
|
|
||||||
Global *Shortener
|
|
||||||
once sync.Once
|
|
||||||
)
|
|
||||||
|
|
||||||
func Init(cfg Config) error {
|
|
||||||
var err error
|
|
||||||
once.Do(func() {
|
|
||||||
Global, err = NewShortener(cfg)
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global instance (duc)
|
|
||||||
var (
|
|
||||||
GlobalIDPool *IDPool
|
|
||||||
oncePopper sync.Once
|
|
||||||
)
|
|
||||||
|
|
||||||
func InitIDPool(db *bbolt.DB, idLen, poolCap, regenThresh int) error {
|
|
||||||
var err error
|
|
||||||
oncePopper.Do(func() {
|
|
||||||
GlobalIDPool, err = NewIDPool(db, idLen, poolCap, regenThresh)
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
41
go.mod
41
go.mod
@ -1,3 +1,44 @@
|
|||||||
module git.tijl.dev/tijl/shortify
|
module git.tijl.dev/tijl/shortify
|
||||||
|
|
||||||
go 1.24.3
|
go 1.24.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.9
|
||||||
|
github.com/hashicorp/golang-lru v1.0.2
|
||||||
|
github.com/marcboeker/go-duckdb/v2 v2.3.4
|
||||||
|
go.etcd.io/bbolt v1.4.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/apache/arrow-go/v18 v18.4.0 // indirect
|
||||||
|
github.com/duckdb/duckdb-go-bindings v0.1.17 // indirect
|
||||||
|
github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.12 // indirect
|
||||||
|
github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.12 // indirect
|
||||||
|
github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.12 // indirect
|
||||||
|
github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.12 // indirect
|
||||||
|
github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.12 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/google/flatbuffers v25.2.10+incompatible // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||||
|
github.com/marcboeker/go-duckdb/arrowmapping v0.0.10 // indirect
|
||||||
|
github.com/marcboeker/go-duckdb/mapping v0.0.11 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||||
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||||
|
golang.org/x/mod v0.25.0 // indirect
|
||||||
|
golang.org/x/sync v0.15.0 // indirect
|
||||||
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
|
golang.org/x/tools v0.34.0 // indirect
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
|
)
|
||||||
|
100
go.sum
Normal file
100
go.sum
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/apache/arrow-go/v18 v18.4.0 h1:/RvkGqH517iY8bZKc4FD5/kkdwXJGjxf28JIXbJ/oB0=
|
||||||
|
github.com/apache/arrow-go/v18 v18.4.0/go.mod h1:Aawvwhj8x2jURIzD9Moy72cF0FyJXOpkYpdmGRHcw14=
|
||||||
|
github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
|
||||||
|
github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/duckdb/duckdb-go-bindings v0.1.17 h1:SjpRwrJ7v0vqnIvLeVFHlhuS72+Lp8xxQ5jIER2LZP4=
|
||||||
|
github.com/duckdb/duckdb-go-bindings v0.1.17/go.mod h1:pBnfviMzANT/9hi4bg+zW4ykRZZPCXlVuvBWEcZofkc=
|
||||||
|
github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.12 h1:8CLBnsq9YDhi2Gmt3sjSUeXxMzyMQAKefjqUy9zVPFk=
|
||||||
|
github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.12/go.mod h1:Ezo7IbAfB8NP7CqPIN8XEHKUg5xdRRQhcPPlCXImXYA=
|
||||||
|
github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.12 h1:wjO4I0GhMh2xIpiUgRpzuyOT4KxXLoUS/rjU7UUVvCE=
|
||||||
|
github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.12/go.mod h1:eS7m/mLnPQgVF4za1+xTyorKRBuK0/BA44Oy6DgrGXI=
|
||||||
|
github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.12 h1:HzKQi2C+1jzmwANsPuYH6x9Sfw62SQTjNAEq3OySKFI=
|
||||||
|
github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.12/go.mod h1:1GOuk1PixiESxLaCGFhag+oFi7aP+9W8byymRAvunBk=
|
||||||
|
github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.12 h1:YGSR7AFLw2gJ7IbgLE6DkKYmgKv1LaRSd/ZKF1yh2oE=
|
||||||
|
github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.12/go.mod h1:o7crKMpT2eOIi5/FY6HPqaXcvieeLSqdXXaXbruGX7w=
|
||||||
|
github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.12 h1:2aduW6fnFnT2Q45PlIgHbatsPOxV9WSZ5B2HzFfxaxA=
|
||||||
|
github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.12/go.mod h1:IlOhJdVKUJCAPj3QsDszUo8DVdvp1nBFp4TUJVdw99s=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||||
|
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||||
|
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||||
|
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
|
||||||
|
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
|
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
||||||
|
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/marcboeker/go-duckdb/arrowmapping v0.0.10 h1:G1W+GVnUefR8uy7jHdNO+CRMsmFG5mFPIHVAespfFCA=
|
||||||
|
github.com/marcboeker/go-duckdb/arrowmapping v0.0.10/go.mod h1:jccUb8TYD0p5TsEEeN4SXuslNJHo23QaKOqKD+U6uFU=
|
||||||
|
github.com/marcboeker/go-duckdb/mapping v0.0.11 h1:fusN1b1l7Myxafifp596I6dNLNhN5Uv/rw31qAqBwqw=
|
||||||
|
github.com/marcboeker/go-duckdb/mapping v0.0.11/go.mod h1:aYBjFLgfKO0aJIbDtXPiaL5/avRQISveX/j9tMf9JhU=
|
||||||
|
github.com/marcboeker/go-duckdb/v2 v2.3.4 h1:o98wrefPbH0IdJRix4pF0+jZiXoFQ+FSR8InMsCUZD0=
|
||||||
|
github.com/marcboeker/go-duckdb/v2 v2.3.4/go.mod h1:8adNrftF4Ye29XMrpIl5NYNosTVsZu1mz3C82WdVvrk=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
|
||||||
|
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||||
|
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
||||||
|
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||||
|
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||||
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
|
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
|
||||||
|
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
|
||||||
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
|
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||||
|
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
|
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
@ -21,13 +21,13 @@ type VisitLog struct {
|
|||||||
Time time.Time
|
Time time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Shortener) LogVisit(log VisitLog) {
|
func (s *Server) LogVisit(log VisitLog) {
|
||||||
s.logChan <- log
|
s.logChan <- log
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Shortener) startLogging() {
|
func (s *Server) startLogging() {
|
||||||
|
|
||||||
db, err := sql.Open("duckdb", s.DataFolder+"/analytics.db")
|
db, err := sql.Open("duckdb", s.dataFolder+"/analytics.db")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
return
|
return
|
||||||
|
135
pkg/client/client.go
Normal file
135
pkg/client/client.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.tijl.dev/tijl/shortify/pkg/generation"
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
serverURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
prefix uint16
|
||||||
|
gen *generation.Generator
|
||||||
|
domain string // e.g. https://sho.rt
|
||||||
|
|
||||||
|
db *bolt.DB
|
||||||
|
retryQueue chan shortenJob
|
||||||
|
stopRetry chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient with persistence and retry queue
|
||||||
|
func NewClient(serverURL, domain string) (*Client, error) {
|
||||||
|
httpClient, baseURL, err := createHTTPClient(serverURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := bolt.Open(dbFileName, 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := &Client{
|
||||||
|
serverURL: baseURL,
|
||||||
|
httpClient: httpClient,
|
||||||
|
domain: domain,
|
||||||
|
db: db,
|
||||||
|
retryQueue: make(chan shortenJob, 1000),
|
||||||
|
stopRetry: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create buckets if not exist
|
||||||
|
err = db.Update(func(tx *bolt.Tx) error {
|
||||||
|
_, err := tx.CreateBucketIfNotExists([]byte(bucketPrefix))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.CreateBucketIfNotExists([]byte(bucketRetryJobs))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load or get prefix
|
||||||
|
var prefix uint16
|
||||||
|
err = db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte(bucketPrefix))
|
||||||
|
v := b.Get([]byte("prefix"))
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(v) != 2 {
|
||||||
|
return fmt.Errorf("invalid prefix length in db")
|
||||||
|
}
|
||||||
|
prefix = binary.BigEndian.Uint16(v)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If prefix not found, register new one and save it
|
||||||
|
if prefix == 0 {
|
||||||
|
prefix, err = cli.registerPrefix()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte(bucketPrefix))
|
||||||
|
buf := make([]byte, 2)
|
||||||
|
binary.BigEndian.PutUint16(buf, prefix)
|
||||||
|
return b.Put([]byte("prefix"), buf)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli.prefix = prefix
|
||||||
|
cli.gen = generation.NewGenerator(prefix)
|
||||||
|
|
||||||
|
// Load retry jobs from DB into channel
|
||||||
|
go cli.loadRetryJobs()
|
||||||
|
|
||||||
|
// Start retry worker
|
||||||
|
go cli.retryWorker()
|
||||||
|
|
||||||
|
return cli, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorten creates a short URL and sends it async to the central server
|
||||||
|
func (c *Client) Shorten(longURL string) string {
|
||||||
|
shortID := c.gen.NextID()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
payload := map[string]string{
|
||||||
|
"id": shortID,
|
||||||
|
"url": longURL,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/shorten", c.serverURL), bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("shorten request build error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("shorten request failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s/%s", c.domain, shortID)
|
||||||
|
}
|
138
pkg/client/store.go
Normal file
138
pkg/client/store.go
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
bucketPrefix = "prefix"
|
||||||
|
bucketRetryJobs = "retry_queue"
|
||||||
|
dbFileName = "shorty_client.db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type shortenJob struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) registerPrefix() (uint16, error) {
|
||||||
|
resp, err := c.httpClient.Post(fmt.Sprintf("%s/register", c.serverURL), "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Prefix uint16 `json:"prefix"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.Prefix, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) loadRetryJobs() {
|
||||||
|
c.db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte(bucketRetryJobs))
|
||||||
|
b.ForEach(func(k, v []byte) error {
|
||||||
|
var job shortenJob
|
||||||
|
err := json.Unmarshal(v, &job)
|
||||||
|
if err == nil {
|
||||||
|
select {
|
||||||
|
case c.retryQueue <- job:
|
||||||
|
default:
|
||||||
|
// channel full, drop or log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) retryWorker() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case job := <-c.retryQueue:
|
||||||
|
err := c.sendShortenJob(job)
|
||||||
|
if err != nil {
|
||||||
|
// Re-enqueue with delay
|
||||||
|
go func(j shortenJob) {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
c.enqueueJob(j)
|
||||||
|
}(job)
|
||||||
|
} else {
|
||||||
|
c.deleteJobFromDB(job)
|
||||||
|
}
|
||||||
|
case <-c.stopRetry:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (c *Client) enqueueJob(job shortenJob) {
|
||||||
|
// store in DB with key = job.ID
|
||||||
|
err := c.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte(bucketRetryJobs))
|
||||||
|
data, err := json.Marshal(job)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.Put([]byte(job.ID), data)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to store job in db:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c.retryQueue <- job:
|
||||||
|
default:
|
||||||
|
log.Println("Retry queue full, dropping job:", job.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) deleteJobFromDB(job shortenJob) {
|
||||||
|
err := c.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte(bucketRetryJobs))
|
||||||
|
return b.Delete([]byte(job.ID))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to delete job from db:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sendShortenJob(job shortenJob) error {
|
||||||
|
payload := map[string]string{
|
||||||
|
"id": job.ID,
|
||||||
|
"url": job.URL,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/shorten", c.serverURL), bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("server returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
close(c.stopRetry)
|
||||||
|
return c.db.Close()
|
||||||
|
}
|
42
pkg/client/transport.go
Normal file
42
pkg/client/transport.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createHTTPClient creates an http.Client with support for HTTP or unix socket transport
|
||||||
|
func createHTTPClient(serverURL string) (*http.Client, string, error) {
|
||||||
|
if strings.HasPrefix(serverURL, "unix://") {
|
||||||
|
// Extract socket path and strip the scheme
|
||||||
|
socketPath := strings.TrimPrefix(serverURL, "unix://")
|
||||||
|
|
||||||
|
// We'll fake a URL host for use in requests
|
||||||
|
baseURL := "http://unix"
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial("unix", socketPath)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 2 * time.Second,
|
||||||
|
}, baseURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default HTTP transport
|
||||||
|
parsed, err := url.Parse(serverURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 2 * time.Second,
|
||||||
|
}, parsed.String(), nil
|
||||||
|
}
|
62
pkg/generation/base62.go
Normal file
62
pkg/generation/base62.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
package generation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
|
||||||
|
var (
|
||||||
|
base = len(alphabet)
|
||||||
|
rev [256]int
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for i := range rev {
|
||||||
|
rev[i] = -1
|
||||||
|
}
|
||||||
|
for i, c := range alphabet {
|
||||||
|
rev[c] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeBase62 encodes a byte slice into a base62 string
|
||||||
|
func EncodeBase62(b []byte) string {
|
||||||
|
var n uint64
|
||||||
|
for i := 0; i < len(b); i++ {
|
||||||
|
n = n<<8 + uint64(b[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
return string(alphabet[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
var s []byte
|
||||||
|
for n > 0 {
|
||||||
|
r := n % uint64(base)
|
||||||
|
s = append([]byte{alphabet[r]}, s...)
|
||||||
|
n = n / uint64(base)
|
||||||
|
}
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeBase62 decodes a base62 string into a byte slice
|
||||||
|
func DecodeBase62(s string) ([]byte, error) {
|
||||||
|
var n uint64
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
val := rev[c]
|
||||||
|
if val == -1 {
|
||||||
|
return nil, errors.New("invalid base62 character")
|
||||||
|
}
|
||||||
|
n = n*uint64(base) + uint64(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover byte slice from integer
|
||||||
|
b := make([]byte, rawIDLength)
|
||||||
|
for i := rawIDLength - 1; i >= 0; i-- {
|
||||||
|
b[i] = byte(n & 0xff)
|
||||||
|
n >>= 8
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
78
pkg/generation/generation.go
Normal file
78
pkg/generation/generation.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package generation
|
||||||
|
|
||||||
|
import (
|
||||||
|
cryptoRand "crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
idSize = 4 // 4 bytes for random part
|
||||||
|
prefixSize = 2 // 2 bytes for client prefix
|
||||||
|
rawIDLength = prefixSize + idSize // total 6 bytes
|
||||||
|
base62Len = 8 // 6 bytes encoded in base62 ~ 8 chars
|
||||||
|
poolSize = 10000
|
||||||
|
)
|
||||||
|
|
||||||
|
type Generator struct {
|
||||||
|
prefix [2]byte
|
||||||
|
idPool chan string
|
||||||
|
mu sync.Mutex
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGenerator initializes the generator with a 2-byte prefix
|
||||||
|
func NewGenerator(prefix uint16) *Generator {
|
||||||
|
var b [2]byte
|
||||||
|
binary.BigEndian.PutUint16(b[:], prefix)
|
||||||
|
|
||||||
|
g := &Generator{
|
||||||
|
prefix: b,
|
||||||
|
idPool: make(chan string, poolSize),
|
||||||
|
}
|
||||||
|
go g.fillPool()
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillPool pre-generates short IDs into the pool
|
||||||
|
func (g *Generator) fillPool() {
|
||||||
|
for {
|
||||||
|
for i := 0; i < poolSize/10; i++ {
|
||||||
|
id := g.generateRawID()
|
||||||
|
g.idPool <- EncodeBase62(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRawID creates 6 bytes: 2-byte prefix + 4-byte random
|
||||||
|
func (g *Generator) generateRawID() []byte {
|
||||||
|
random := make([]byte, idSize)
|
||||||
|
_, err := cryptoRand.Read(random)
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to read random bytes: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := make([]byte, rawIDLength)
|
||||||
|
copy(raw[0:2], g.prefix[:])
|
||||||
|
copy(raw[2:], random)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextID returns the next available short ID from the pool
|
||||||
|
func (g *Generator) NextID() string {
|
||||||
|
return <-g.idPool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeID (optional) – for analytics/debugging
|
||||||
|
func DecodeID(shortID string) (prefix uint16, randomPart []byte, err error) {
|
||||||
|
raw, err := DecodeBase62(shortID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
if len(raw) != rawIDLength {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
prefix = binary.BigEndian.Uint16(raw[0:2])
|
||||||
|
randomPart = raw[2:]
|
||||||
|
return
|
||||||
|
}
|
80
prefixes.go
Normal file
80
prefixes.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package shortify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoPrefixes = errors.New("no more prefixes available")
|
||||||
|
)
|
||||||
|
|
||||||
|
type PrefixManager struct {
|
||||||
|
db *bolt.DB
|
||||||
|
bucket []byte
|
||||||
|
maxID uint16
|
||||||
|
allocated map[uint16]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPrefixManager(db *bolt.DB) (*PrefixManager, error) {
|
||||||
|
pm := &PrefixManager{
|
||||||
|
db: db,
|
||||||
|
bucket: []byte("prefixes"),
|
||||||
|
allocated: make(map[uint16]struct{}),
|
||||||
|
maxID: 0, // highest allocated prefix so far
|
||||||
|
}
|
||||||
|
// create bucket if not exists and load allocated prefixes
|
||||||
|
err := db.Update(func(tx *bolt.Tx) error {
|
||||||
|
_, err := tx.CreateBucketIfNotExists(pm.bucket)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// load existing prefixes
|
||||||
|
err = db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(pm.bucket)
|
||||||
|
return b.ForEach(func(k, v []byte) error {
|
||||||
|
if len(k) != 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := binary.BigEndian.Uint16(k)
|
||||||
|
pm.allocated[id] = struct{}{}
|
||||||
|
if id > pm.maxID {
|
||||||
|
pm.maxID = id
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllocateNewPrefix assigns next prefix > 0
|
||||||
|
func (pm *PrefixManager) AllocateNewPrefix() (uint16, error) {
|
||||||
|
pm.maxID++
|
||||||
|
if pm.maxID == 0 {
|
||||||
|
// skip zero because reserved for server
|
||||||
|
pm.maxID++
|
||||||
|
}
|
||||||
|
if pm.maxID == 0xFFFF {
|
||||||
|
return 0, ErrNoPrefixes
|
||||||
|
}
|
||||||
|
|
||||||
|
// persist allocation
|
||||||
|
err := pm.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(pm.bucket)
|
||||||
|
key := make([]byte, 2)
|
||||||
|
binary.BigEndian.PutUint16(key, pm.maxID)
|
||||||
|
return b.Put(key, []byte{1})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
pm.allocated[pm.maxID] = struct{}{}
|
||||||
|
return pm.maxID, nil
|
||||||
|
}
|
52
serve.go
Normal file
52
serve.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package shortify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) Admin() *fiber.App {
|
||||||
|
a := fiber.New()
|
||||||
|
|
||||||
|
a.Get("/register", func(c *fiber.Ctx) error {
|
||||||
|
prefix, err := s.prefixManager.AllocateNewPrefix()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var response []byte
|
||||||
|
binary.LittleEndian.PutUint16(response, prefix)
|
||||||
|
return c.Send(response)
|
||||||
|
})
|
||||||
|
|
||||||
|
a.Post("/shorten", func(c *fiber.Ctx) error {
|
||||||
|
shortUrl := c.Query("s")
|
||||||
|
longUrl := string(c.Body())
|
||||||
|
|
||||||
|
return s.storage.Put(shortUrl, longUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// util
|
||||||
|
func GetUnixListener(path string) (net.Listener, error) {
|
||||||
|
|
||||||
|
// Remove old socket if exists
|
||||||
|
socketPath := path
|
||||||
|
_ = os.Remove(socketPath)
|
||||||
|
|
||||||
|
// Create a Unix socket listener
|
||||||
|
ln, err := net.Listen("unix", socketPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally set permissions so other processes can connect
|
||||||
|
_ = os.Chmod(socketPath, 0666)
|
||||||
|
|
||||||
|
return ln, nil
|
||||||
|
|
||||||
|
}
|
95
server.go
95
server.go
@ -2,63 +2,41 @@ package shortify
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wow
|
var ErrNotFound = errors.New("short URL not found")
|
||||||
func (s *Shortener) ServeSocket(path string) {
|
|
||||||
|
|
||||||
// Remove old socket if exists
|
// GetURL first checks cache, then DB, and updates cache on hit
|
||||||
socketPath := path
|
func (s *Server) GetURL(id string) (string, error) {
|
||||||
_ = os.Remove(socketPath)
|
s.cacheLock.RLock()
|
||||||
|
if val, ok := s.cache.Get(id); ok {
|
||||||
|
s.cacheLock.RUnlock()
|
||||||
|
return val.(string), nil
|
||||||
|
}
|
||||||
|
s.cacheLock.RUnlock()
|
||||||
|
|
||||||
// Create a Unix socket listener
|
url, err := s.storage.Get(id)
|
||||||
ln, err := net.Listen("unix", socketPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to listen on unix socket: %v", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally set permissions so other processes can connect
|
s.cacheLock.Lock()
|
||||||
_ = os.Chmod(socketPath, 0666)
|
s.cache.Add(id, url)
|
||||||
|
s.cacheLock.Unlock()
|
||||||
|
|
||||||
app := fiber.New()
|
return url, nil
|
||||||
|
|
||||||
app.Post("/s", s.HandlePOSTShortURLDirect())
|
|
||||||
|
|
||||||
log.Fatal(app.Listener(ln))
|
|
||||||
}
|
|
||||||
func (s *Shortener) ServeHTTP(addr string) {
|
|
||||||
app := fiber.New()
|
|
||||||
|
|
||||||
app.Get("/s/:id", s.HandleGETShortURL())
|
|
||||||
|
|
||||||
log.Fatal(app.Listen(addr))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Shortener) NewShortURL(longUrl string) string {
|
/* Simple routes to use for fiber below! */
|
||||||
shortID, err := s.idPool.PopID()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.put(shortID, longUrl)
|
func (s *Server) HandleGetURL() func(*fiber.Ctx) error {
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return shortID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Shortener) HandleGETShortURL() func(*fiber.Ctx) error {
|
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
shortID := c.Params("id")
|
shortID := c.Params("id")
|
||||||
|
|
||||||
url, err := s.get(shortID)
|
url, err := s.GetURL(shortID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -81,42 +59,17 @@ func (s *Shortener) HandleGETShortURL() func(*fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Shortener) HandlePOSTShortURL() func(*fiber.Ctx) error {
|
func (s *Server) HandlePostURL() func(*fiber.Ctx) error {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
longUrl := string(c.Body())
|
|
||||||
|
|
||||||
if longUrl == "" {
|
targetUrl := string(c.Body())
|
||||||
return errors.New("whut")
|
|
||||||
}
|
|
||||||
|
|
||||||
shortID, err := s.idPool.PopID()
|
newId := s.serverGen.NextID()
|
||||||
if err != nil {
|
|
||||||
|
if err := s.storage.Put(newId, targetUrl); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.put(shortID, longUrl)
|
return c.SendString(newId)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.SendString(shortID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Shortener) HandlePOSTShortURLDirect() func(*fiber.Ctx) error {
|
|
||||||
return func(c *fiber.Ctx) error {
|
|
||||||
longUrl := string(c.Body())
|
|
||||||
shortID := c.Query("s")
|
|
||||||
|
|
||||||
if longUrl == "" || shortID == "" {
|
|
||||||
return errors.New("no comment")
|
|
||||||
}
|
|
||||||
|
|
||||||
err := s.put(shortID, longUrl)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.SendString(shortID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
49
shortify.go
49
shortify.go
@ -3,56 +3,55 @@ package shortify
|
|||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"git.tijl.dev/tijl/shortify/pkg/generation"
|
||||||
"github.com/hashicorp/golang-lru"
|
"github.com/hashicorp/golang-lru"
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Shortener struct {
|
type Server struct {
|
||||||
DataFolder string
|
storage *Storage
|
||||||
|
prefixManager *PrefixManager
|
||||||
db *bolt.DB
|
serverGen *generation.Generator
|
||||||
cache *lru.Cache
|
cache *lru.Cache
|
||||||
accessCache *lru.Cache
|
cacheLock sync.RWMutex
|
||||||
|
|
||||||
|
dataFolder string
|
||||||
|
|
||||||
logChan chan VisitLog
|
logChan chan VisitLog
|
||||||
idPool *IDPool
|
|
||||||
|
|
||||||
writeChan chan [2]string // queue of writes: [shortID, longURL]
|
|
||||||
memStore sync.Map // thread-safe built-in
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DataFolder string
|
DataFolder string
|
||||||
CacheSize int
|
|
||||||
AccessLogSize int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewShortener(cfg Config) (*Shortener, error) {
|
func New(cfg Config) (*Server, error) {
|
||||||
db, err := bolt.Open(cfg.DataFolder+"/database.db", 0600, nil)
|
db, err := bolt.Open(cfg.DataFolder+"/database.db", 0600, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
urlCache, _ := lru.New(cfg.CacheSize)
|
prefixManager, err := NewPrefixManager(db)
|
||||||
accessCache, _ := lru.New(cfg.AccessLogSize)
|
|
||||||
|
|
||||||
idPool, err := NewIDPool(db, 8, 10000, 2000)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &Shortener{
|
storage, err := NewStorage(db)
|
||||||
DataFolder: cfg.DataFolder,
|
if err != nil {
|
||||||
db: db,
|
return nil, err
|
||||||
cache: urlCache,
|
}
|
||||||
accessCache: accessCache,
|
|
||||||
|
cache, _ := lru.New(10000)
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
dataFolder: cfg.DataFolder,
|
||||||
|
storage: storage,
|
||||||
|
cache: cache,
|
||||||
|
serverGen: generation.NewGenerator(0),
|
||||||
|
prefixManager: prefixManager,
|
||||||
logChan: make(chan VisitLog, 1000),
|
logChan: make(chan VisitLog, 1000),
|
||||||
writeChan: make(chan [2]string, 1000),
|
|
||||||
idPool: idPool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
go s.startLogging()
|
go s.startLogging()
|
||||||
go s.asyncDBWriter()
|
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
90
storage.go
90
storage.go
@ -1,71 +1,45 @@
|
|||||||
package shortify
|
package shortify
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
bolt "go.etcd.io/bbolt"
|
||||||
"log"
|
|
||||||
|
|
||||||
"go.etcd.io/bbolt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var bucketName = []byte("shorturls")
|
type Storage struct {
|
||||||
|
db *bolt.DB
|
||||||
func (s *Shortener) get(shortID string) (string, error) {
|
bucket []byte
|
||||||
if val, ok := s.memStore.Load(shortID); ok {
|
|
||||||
s.cache.Add(shortID, val.(string))
|
|
||||||
return val.(string), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if val, ok := s.cache.Get(shortID); ok {
|
func NewStorage(db *bolt.DB) (*Storage, error) {
|
||||||
return val.(string), nil
|
bucket := []byte("shorturls")
|
||||||
}
|
err := db.Update(func(tx *bolt.Tx) error {
|
||||||
|
_, err := tx.CreateBucketIfNotExists(bucket)
|
||||||
var longURL string
|
|
||||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
|
||||||
b := tx.Bucket(bucketName)
|
|
||||||
if b == nil {
|
|
||||||
return errors.New("not found")
|
|
||||||
}
|
|
||||||
v := b.Get([]byte(shortID))
|
|
||||||
if v != nil {
|
|
||||||
longURL = string(v)
|
|
||||||
s.cache.Add(shortID, longURL)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return errors.New("not found")
|
|
||||||
})
|
|
||||||
|
|
||||||
return longURL, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Shortener) put(shortID, longURL string) error {
|
|
||||||
|
|
||||||
s.memStore.Store(shortID, longURL)
|
|
||||||
s.cache.Add(shortID, longURL)
|
|
||||||
|
|
||||||
// Queue write to DB (non-blocking)
|
|
||||||
select {
|
|
||||||
case s.writeChan <- [2]string{shortID, longURL}:
|
|
||||||
default:
|
|
||||||
log.Println("Warning: write queue full, short URL write may be dropped")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Shortener) asyncDBWriter() {
|
|
||||||
for pair := range s.writeChan {
|
|
||||||
shortID := pair[0]
|
|
||||||
longURL := pair[1]
|
|
||||||
|
|
||||||
err := s.db.Update(func(tx *bbolt.Tx) error {
|
|
||||||
b, err := tx.CreateBucketIfNotExists(bucketName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
return b.Put([]byte(shortID), []byte(longURL))
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("DB write failed for %s: %v", shortID, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return &Storage{db: db, bucket: bucket}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Put(id, url string) error {
|
||||||
|
return s.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(s.bucket)
|
||||||
|
return b.Put([]byte(id), []byte(url))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Get(id string) (string, error) {
|
||||||
|
var url []byte
|
||||||
|
err := s.db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(s.bucket)
|
||||||
|
url = b.Get([]byte(id))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if url == nil {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return string(url), nil
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user