cmd/golink: persist click stats for links

store click stats in db.  Hold stats in memory and flush to db every
minute, as well as whenever the /_/export endpoint is called.

Change DB.List() to DB.LoadAll(), since it practice we actually want
the data, not just the names.

Change-Id: I21a3aa19bfc065d595822f004a14b96bbb347de8
This commit is contained in:
Will Norris
2022-06-10 13:21:39 -07:00
parent 3b87ecf59d
commit 811dd5ae87
3 changed files with 93 additions and 14 deletions
+15 -4
View File
@@ -17,12 +17,13 @@ type Link struct {
Created time.Time
LastEdit time.Time // when the link was last edited
Owner string // user@domain
Clicks int // number of times this link has been served
}
// DB provides storage for Links.
type DB interface {
// List the short name of all stored Links.
List() ([]string, error)
// LoadAll returns all stored Links.
LoadAll() ([]*Link, error)
// Load a Link by its short name. It returns fs.ErrNotExist if the link does not exist.
Load(short string) (*Link, error)
@@ -67,7 +68,7 @@ func (f *FileDB) linkPath(short string) string {
return filepath.Join(f.dir, name)
}
func (f *FileDB) List() ([]string, error) {
func (f *FileDB) LoadAll() ([]*Link, error) {
d, err := os.Open(f.dir)
if err != nil {
return nil, err
@@ -78,7 +79,17 @@ func (f *FileDB) List() ([]string, error) {
if err != nil {
return nil, err
}
return names, nil
links := make([]*Link, len(names))
for i, short := range names {
link, err := f.Load(short)
if err != nil {
return nil, err
}
links[i] = link
}
return links, nil
}
func (f *FileDB) Load(short string) (*Link, error) {
+77 -9
View File
@@ -37,6 +37,9 @@ var (
var stats struct {
mu sync.Mutex
clicks map[string]int // short link -> number of times visited
// dirty identifies short links with clicks that have not yet been stored.
dirty map[string]bool
}
//go:embed link-snapshot.json
@@ -72,7 +75,15 @@ func main() {
log.Fatalf("NewFileDB(%q): %v", *linkDir, err)
}
restoreLastSnapshot()
if err := restoreLastSnapshot(); err != nil {
log.Printf("restoring snapshot: %v", err)
}
if err := initStats(); err != nil {
log.Printf("initializing stats: %v", err)
}
// flush stats periodically
go flushStatsLoop()
http.HandleFunc("/", serveGo)
http.HandleFunc("/_/export", serveExport)
@@ -125,6 +136,57 @@ func init() {
homeCreate = template.Must(template.ParseFS(embeddedFS, "home.html"))
}
// initStats initializes the in-memory stats counter with counts from db.
func initStats() error {
links, err := db.LoadAll()
if err != nil {
return err
}
stats.mu.Lock()
defer stats.mu.Unlock()
stats.clicks = make(map[string]int)
for _, link := range links {
if link.Clicks > 0 {
stats.clicks[link.Short] = link.Clicks
}
}
return nil
}
// flushStats writes any pending link stats to db.
func flushStats() error {
stats.mu.Lock()
defer stats.mu.Unlock()
for short := range stats.dirty {
link, err := db.Load(short)
if err != nil {
return err
}
if link.Clicks != stats.clicks[short] {
link.Clicks = stats.clicks[short]
if err := db.Save(link); err != nil {
return err
}
}
delete(stats.dirty, short)
}
return nil
}
// flushStatsLoop will flush stats every minute. This function never returns.
func flushStatsLoop() {
for {
if err := flushStats(); err != nil {
log.Printf("flushing stats: %v", err)
}
time.Sleep(time.Minute)
}
}
func serveHome(w http.ResponseWriter, short string) {
var clicks []visitData
@@ -199,6 +261,10 @@ func serveGo(w http.ResponseWriter, r *http.Request) {
stats.clicks = make(map[string]int)
}
stats.clicks[link.Short]++
if stats.dirty == nil {
stats.dirty = make(map[string]bool)
}
stats.dirty[link.Short] = true
stats.mu.Unlock()
target, err := expandLink(link.Long, expandEnv{Now: time.Now().UTC(), Path: remainder})
@@ -329,19 +395,21 @@ func serveSave(w http.ResponseWriter, r *http.Request) {
// and printed one per line. This format is used to restore link snapshots on
// startup.
func serveExport(w http.ResponseWriter, r *http.Request) {
names, err := db.List()
if err := flushStats(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
links, err := db.LoadAll()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sort.Strings(names)
sort.Slice(links, func(i, j int) bool {
return links[i].Short < links[j].Short
})
encoder := json.NewEncoder(w)
for _, name := range names {
link, err := db.Load(name)
if err != nil {
panic(http.ErrAbortHandler)
}
for _, link := range links {
if err := encoder.Encode(link); err != nil {
panic(http.ErrAbortHandler)
}
+1 -1
View File
@@ -24,7 +24,7 @@
<button type=submit class="py-2 px-4 my-2 rounded-md bg-blue-500 border-blue-500 text-white hover:bg-blue-600 hover:border-blue-600">Create</button>
</form>
<h2 class="text-xl font-bold pt-6 pb-2">Recent Popular Links</h2>
<h2 class="text-xl font-bold pt-6 pb-2">Popular Links</h2>
<table class="table-auto ">
<thead class="border-b border-gray-200 uppercase text-xs text-gray-500 text-left">
<tr>