shortify/generation.go
2025-08-06 10:23:12 +02:00

219 lines
3.9 KiB
Go

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
}