Post

How I built RoommateFinder (and how i optimized it with Go)

How I built RoommateFinder (and how i optimized it with Go)

So I initially got the inspiration when I saw a terribly vibe coded website from a senior from my university, at which time i was also looking for an excuse to somehow learn Next-JS framework and Go. It’s kinda important to me that frontend should look alright so i picked NextJS for the project, and Go was my natural choice because i wanted to learn an objectively high performant language i could build a backend on.

Goals

Specifically I had some goals pertaining to my project -

I wanted to learn

  • Go concurrency, from scratch, then be able to effectively intuitively build schema that would leverage the abilities of Go. My general backend used to be a Python Flask API that usually was my goto if i wanted to script something fast, but hey, i gotta grow to better things.
  • Next-JS frontend - I wanted to be able to build a minimal but good looking frontend with minimal effort, and learn React jsx as well as Tailwind
  • PostgresSQL - Since I was working primarily with users, registration and sessions, what better way than to work on one of the most compatible, well known databases in the world. I have had some MongoDB and Firebase DB experience but since i’ve heard Postgres is highly optimized, i wanted to leverage that for my site.
  • Dockerization - I once built a docker container for a pwn ctf. I know how exactly they work and for what reasons they’re used - but easier said than done. I might as well learn everything for containerization including proper Dockerfile language and Docker Compose setups

Frontend

So, what all do I already know?

Well, i’ve been using pure HTML CSS JS for the majority of my years in webdev (yuck i know).

The thing about using raw CSS is that it almost never turns out well, in fact majority of frontend is dependent on reusable assets, learning proper UI design, tricks, plugins and things that would probably befuddle a backend programmer like me.

This is why my primary target was to first learn JSX (which is the way to HTMX rendering) which meant learning useState as well as useEffect. Couldn’t have been very hard except for the fact that I’m extremely ass at frontend design and the best i could do was like 2 rectangles and 2 buttons.

I made like a small really shitty project without any styling or tailwind just to get the hang of basic JSX functioning because it boggled my mind ever since 10th grade how people could just write JSX. But i got the hang of it anyway.

Another thing I had to learn on my own was flexbox and grid for tailwind css.

As you can assume, after learning about components and quickly realising i didnt have to style my own components (yuck), I installed the daisyUI plugin into the project and quickly realised - “Wow, I really do not know how to place components on a page even if i knew how to copy them”

I do plan to get the grip of making better frontends by looking over the homework of some well done sites with dynamic sexy looking front pages - but damn, are they mostly all vibecoded kinda kills my motivation.

like brother seriously this was the best i could do for day 1 of building the frontend first

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import Navbar from  "./components/navbar";
export  default  function  Home() {
return (
<div>
<Navbar  />
<div  className="flex h-screen max-h-screen justify-center min-h-screen items-center gap-4 bg-blue-100">
	<div  className="card bg-white shadow h-1/2 w-1/3">
		<div  className="card-body flex flex-col justify-evenly text-center items-center">
				<h1  className="text-5xl">Add yourself</h1>
					<h5  className="text-center text-2xl">alright bro what do you wanna do add yourself?</h5>
				<div  className="justify-center card-actions">
				<a  className="btn btn-primary"  href="/register">Register</a>
		</div>
	</div>
</div>
<div  className="card bg-white shadow h-1/2 w-1/3">
		<div  className="card-body flex flex-col justify-evenly text-center items-center">
			<h1  className="text-5xl">Search</h1>
			<h5  className="text-center text-2xl">browse the blocks broski</h5>
			<div  className="justify-center card-actions">
				<a  className="btn btn-primary"  href="/blocks">Browse</a>
			</div>
		</div>
	</div>
</div>
</div >
);
}

Backend Engineering

Finally, something i can talk about without an inferiority complex.

So at first I tried implementing the Go backend on my first shitty project. I had mostly looked forward to using the Ratelimit function i had which was the most complex thing happening in the code at the moment.

1
2
3
4
5
6
7
8
9
func Routine() {
	for  range ticker.C {
		select {
			case ratelimit <- time.Now():
			default:
		}
	}
}
1
2
3
4
5
6
7
8
9
10
11
func Ratelimit(handlerfunc  http.HandlerFunc) http.HandlerFunc {
	return  func(w  http.ResponseWriter, req  *http.Request) {
		select {
			case  <-ratelimit:
				handlerfunc(w, req)
			default:
				fmt.Println("[RATELIMIT EXCEEDED] Packet left out")
			}
		}
	}
}

As you can see, absolutely baby shit. I have no excuse - i’m a lightweight at writing systems languages. The most programming i’ve ever done in systems is when i’ve written physics simulations with C.

Go itself is taking it easy on me by not a headache to deal with. In fact I had just discovered how to distribute my code into separate files (which i desperately needed)

But anyways onward to writing my raw API v1

My raw API v1

I’ll explain it to you in a nutshell.

Basically what i needed (to rip off the vibe coded website) was to pretty much these endpoints

  • /blocks
  • /rooms?block=
  • /registration
  • /login

Each one of them serves its own purpose, /blocks shows you exactly what blocks are available to click on, and how many are Fully filled or Partially filled -

1
2
3
4
type  Block  struct {
	Partial int
	Full int
}

Each person has their own struct

1
2
3
4
5
6
7
8
9
10
11
12
13
type  Person  struct {
	Admnno string
	Name string
	Social string
	Socialtype string
	Roomno string
	Blockno string
	Created_at string
}

type  Room  struct {
People []*Person
}

Privacy

Before you yell at me - NO, I was not going to use raw admission numbers from our university. That would defeat the point of their privacy, and since our university isn’t linked to the site in any way officially, that would mean data exposition would lead to some shady shit.

Instead my plan was this - Let’s just hash the admission number, before it even hits the database, or the backend.

Now, I hear you asking, “Dumbass, that means your frontend contains your encryption method, if they know your pepper, attackers can just brute force your admission numbers against the hashes”

Here’s where it gets interesting - We hash it twice. Once in the frontend, the other time in the backend. Why? Why not just hash it in the backend?

Well the simple explanation is - I don’t want even a singular leak of someone’s real admission number, that means not even a single request to the server should contain someone’s private data.

Imagine this

Attacker has finally managed to get a handle on my backend. He’s snuck in, finally placed his malware that hooked the process and he’s now reading the requests that come in.

Yeah, this is why.

So the reason why it was purely written as “Admnno” in the struct was because i hadn’t quite got to hashing yet, and i was operating under admnno for the duration of testing.

Block

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Occupancies := make(map[string]map[string]int, 0)
rows := db.Query("SELECT * FROM people", globals.Globaldb)

Block := make(map[string]*structs.Block, 0)

for _, row := range rows {
	if _, ok := Occupancies[row.Blockno]; ok {
		Occupancies[row.Blockno][row.Roomno] = Occupancies[row.Blockno][row.Roomno] + 1
	} else {
		Occupancies[row.Blockno] = make(map[string]int, 0)
		Occupancies[row.Blockno][row.Roomno] = 1
	}
}

Block := make(map[string]*structs.Block, 0)

for key := range Occupancies {
	Block[key] = &structs.Block{Partial: 0, Full: 0}
	for _, occupancy := range Occupancies[key] {
		if occupancy >= 2 { // remind me to check the occupancy max limit later
			Block[key].Full = Block[key].Full + 1
		} else {
			Block[key].Partial = Block[key].Partial + 1
		}
	}
}

Honestly one of the more simpler ways would’ve been to experiment with Querying methods, which could’ve been cleaner but I felt like i wanted to rely more on my Go backend for this particular project.

It is objectively faster to do this level of filtering with a well formed SQL query but ya know… there’s other stuff i want to add.

Version 2: Sessions and Login

So the idea is simple, as soon as a user logs in or registers, give them a session ID cookie which for the duration of validity can be used to automatically stay logged in. You send a /verify to verify the token everytime you visit the site.

Easy to say, hard to manage.

The main problem was the CORS that absolutely refused the living hell out of placing a cookie on a god forbid user.

Infact, i forgot that the Response was also sending back headers and the browser actually checks them?

1
2
3
4
5
6
func Verify(w http.ResponseWriter, req *http.Request) {
	w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000")
	w.Header().Set("Access-Control-Allow-Credentials", "true")
	w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
	w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
	w.Header().Set("Content-Type", "application/text")

If you don’t do this, it throws CORS error, because browsers will allow you to send data to that resources but if it doesn’t send the right headers back, it could be malicious. So browsers invented a genius way to protect the user - which involved blocking the site altogether, leading me to try to figure out what the hell is going wrong for an hour straight.

I’ve dealt with CORS while working on javascript projects before, but i hate the fact that some random headers that look kind of useless on the outside have massive implications on the browser.

Another thing while learning this realisation was that Server components in React are asynchronous, while Client components are synchronous. This was intuitive to me but came as a shock to me.

1
2
3
4
5
6
7
useEffect(() => {
    fetch("http://localhost:8080/verify", { credentials: "include" })
      .then(res => res.text())
      .then(data => setValid(data === "valid"))
      .catch(() => setValid(false));
  }, []);

Client module

1
2
3
const { id } = await params; // Await params directly on the server
const res = await fetch(`http://localhost:8080/rooms?block=${id}`);
const loaded_data = await res.json();

Server module

The main fact is that server components have the indispensable ability to just wait it out.

Logging

Generally logging is not my specialty. Neither is keeping track of explaining my code. But i’m trying to level up my production level game, and that requires me to debug my code. Golang is notoriously hard to debug compared to Python simply because the errors make absolutely zero sense sometimes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
func Blocks(w http.ResponseWriter, req *http.Request) {

	logrus.WithFields(logrus.Fields{
		"package":  "handlers",
		"endpoint": "/blocks",
		"method":   req.Method,
		"remote":   req.RemoteAddr,
	}).Info("requested /blocks by ", req.RemoteAddr)

	w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000")
	w.Header().Set("Access-Control-Allow-Credentials", "true")
	w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
	w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
	w.Header().Set("Content-Type", "application/text")

	Occupancies := make(map[string]map[string]int, 0)
	rows := db.Query("SELECT * FROM people", globals.Globaldb)

	logrus.WithFields(logrus.Fields{
		"package":  "handlers",
		"endpoint": "/blocks",
		"rows":     len(rows),
		"method":   req.Method,
		"remote":   req.RemoteAddr,
	}).Info("fetched rows from postgres db")

	for _, row := range rows {
		if _, ok := Occupancies[row.Blockno]; ok {
			Occupancies[row.Blockno][row.Roomno] = Occupancies[row.Blockno][row.Roomno] + 1
		} else {
			Occupancies[row.Blockno] = make(map[string]int, 0)
			Occupancies[row.Blockno][row.Roomno] = 1
		}
	}

	logrus.WithFields(logrus.Fields{
		"package":     "handlers",
		"endpoint":    "/blocks",
		"occupancies": len(Occupancies),
		"method":      req.Method,
		"remote":      req.RemoteAddr,
	}).Debug("made occupancies map")

	Block := make(map[string]*structs.Block, 0)

	for key := range Occupancies {
		Block[key] = &structs.Block{Partial: 0, Full: 0}
		for _, occupancy := range Occupancies[key] {
			if occupancy >= 2 { // remind me to check the occupancy max limit later
				Block[key].Full = Block[key].Full + 1
			} else {
				Block[key].Partial = Block[key].Partial + 1
			}
		}
	}

	logrus.WithFields(logrus.Fields{
		"package":  "handlers",
		"endpoint": "/blocks",
		"blocks":   len(Block),
		"method":   req.Method,
		"remote":   req.RemoteAddr,
	}).Debug("made blocks map")

	str, err := json.Marshal(Block)

	if err != nil {
		logrus.WithFields(logrus.Fields{
			"package":   "handlers",
			"endpoint":  "/blocks",
			"jsonified": len(str),
			"method":    req.Method,
			"remote":    req.RemoteAddr,
		}).Error("error in JSON.Marshal")
	}

	logrus.WithFields(logrus.Fields{
		"package":   "handlers",
		"endpoint":  "/blocks",
		"jsonified": len(str),
		"status":    http.StatusOK,
		"method":    req.Method,
		"remote":    req.RemoteAddr,
	}).Info("response sent")

	fmt.Fprintf(w, "%s", string(str))
}

There’s different levels of logging as far as i could tell, i only needed 4 - Info, Debug, Error, Fatal

There was also Trace but i don’t think i was coding anything that big in size for me to have to trace back to anything.

So now, our logs look like this -

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
INFO[2026-07-08T18:40:37Z] requested /blocks by [::1]:48478              endpoint=/blocks method=GET package=handlers remote="[::1]:48478"
DEBU[2026-07-08T18:40:37Z] fetch query recieved, making results..        function=query package=db
DEBU[2026-07-08T18:40:37Z] returning results from query...               function=query package=db results=7
DEBU[2026-07-08T18:40:37Z] fetched rows from postgres db                 endpoint=/blocks package=caching
DEBU[2026-07-08T18:40:37Z] made occupancies map                          endpoint=/blocks occupancies=5 package=caching
DEBU[2026-07-08T18:40:37Z] made blocks map                               blocks=5 endpoint=/blocks package=caching
DEBU[2026-07-08T18:40:37Z] updating the cache..                          blocks=5 endpoint=/blocks package=caching
DEBU[2026-07-08T18:40:37Z] updated the cache.                            cache=5 package=caching
DEBU[2026-07-08T18:40:37Z] CACHE MISS!                                  
INFO[2026-07-08T18:40:37Z] response sent                                 endpoint=/blocks jsonified=140 method=GET package=handlers remote="[::1]:48478" status=200
DEBU[2026-07-08T18:40:37Z] checking session in db                        endpoint=/verify method=GET package=handlers
INFO[2026-07-08T18:40:37Z] no matching session found in db               endpoint=/verify package=handlers token=a452f271b62de95d2bc7af2a3cb45d53
DEBU[2026-07-08T18:40:38Z] checking session in db                        endpoint=/verify method=GET package=handlers
INFO[2026-07-08T18:40:38Z] requested /blocks by [::1]:48476              endpoint=/blocks method=GET package=handlers remote="[::1]:48476"
DEBU[2026-07-08T18:40:38Z] CACHE HIT!                                   
INFO[2026-07-08T18:40:38Z] response sent                                 endpoint=/blocks jsonified=140 method=GET package=handlers remote="[::1]:48476" status=200
INFO[2026-07-08T18:40:38Z] no matching session found in db               endpoint=/verify package=handlers token=a452f271b62de95d2bc7af2a3cb45d53

I know.. it looks amazing. Simply being able to locate an error in processing or having a LOG stored of it is a huge advantage in debugging and reducing a LOT of debugging time.

Testing

So the project is pretty big at this point. Debugging is getting still progressively difficult. At this point I had even more difficult things. I also needed a test to make sure at any point of the process, we didn’t mess up what already worked.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
func TestSignupflow(t *testing.T) {
	godotenv.Load("../../.env")

	initiation.Database()

	// registration flow
	logrus.Info("/registration testing in progress..")

	req := httptest.NewRequest("GET", "/registration?admnno=69&name=Shaurya&social=discordusername&socialtype=Discord&blockno=16&roomno=123&created_at=now", nil)

	w := httptest.NewRecorder()

	handlers.RegistrationHandler(w, req)

	resp := w.Result()
	body := w.Body.String()

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	if body != "done" {
		t.Errorf("expected 'done', got %q", body)
	}
	cookies := resp.Cookies()
	if len(cookies) == 0 {
		t.Fatal("expected a cookie, got none")
	}
	token := cookies[0].Value

	logrus.Info("/registration testing in complete..")

	defer func() {
		globals.Globaldb.Exec("DELETE FROM people WHERE admn_hash='69'")
		globals.Globaldb.Exec("DELETE FROM sessions WHERE admnno='69'")
	}()

	logrus.Info("got token as (assume its not malformed) ", token)

	logrus.Info("/verify testing in progress..")

	req = httptest.NewRequest("GET", "/verify", nil)
	req.AddCookie(&http.Cookie{Name: "sess_id", Value: token})
	w = httptest.NewRecorder()

	handlers.Verify(w, req)

	resp = w.Result()
	body = w.Body.String()

	if resp.StatusCode != http.StatusOK {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}

	if body != "valid" {
		t.Errorf("expected 'valid', got %q", body)
	}

	logrus.Info("/verify ended..")

	// logout flow
	logrus.Info("/logout testing in progress..")

	req = httptest.NewRequest("GET", "/logout", nil)

	req.AddCookie(&http.Cookie{Name: "sess_id", Value: token})
	w = httptest.NewRecorder()

	handlers.Logout(w, req)

	resp = w.Result()
	body = w.Body.String()

	if resp.StatusCode != http.StatusOK {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}

	if body != "logged out" {
		t.Errorf("expected 'logged out', got %q", body)
	}

	logrus.Info("/logout testing complete..")

	// login flow
	logrus.Info("/login testing in progress..")

	req = httptest.NewRequest("GET", "/login?admn_hash=69&name=Shaurya", nil)

	w = httptest.NewRecorder()

	handlers.Login(w, req)

	resp = w.Result()
	body = w.Body.String()

	if resp.StatusCode != http.StatusOK {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}

	if body == "not found" {
		t.Errorf("expected token, got %q", body)
	}

	logrus.Info("/login testing complete..")

}

It’s pretty elegant. It tests the entire sign up flow with log out, session validity and verification. This is just to make sure that when i add the caching mechanisms, i was going to not give up any more time trying to fix something in a project as big as this, especially in a language i spent only 3 days learning through gobyexample.com

Registration handler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func TestRegistrationHandler(t *testing.T) {
	if err := godotenv.Load("../../.env"); err != nil {
		t.Fatalf("failed to load .env: %v", err)
	}
	initfuncs.Database()

	req := httptest.NewRequest("GET",
		"/registration?admnno=69&name=Shaurya&social=discordusername&socialtype=Discord&blockno=16&roomno=123&created_at=now",
		nil)
	w := httptest.NewRecorder()

	handlers.RegistrationHandler(w, req)

	resp := w.Result()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if w.Body.String() != "done" {
		t.Errorf("expected 'done', got %q", w.Body.String())
	}

	cookies := resp.Cookies()
	if len(cookies) == 0 {
		t.Fatal("expected a cookie, got none")
	}

	if _, err := globals.Globaldb.Exec("DELETE FROM people WHERE admn_hash=$1", "69"); err != nil {
		t.Logf("cleanup people failed: %v", err)
	}
	if _, err := globals.Globaldb.Exec("DELETE FROM sessions WHERE admnno=$1", "69"); err != nil {
		t.Logf("cleanup sessions failed: %v", err)
	}
}

Login handler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package individualflows

import (
	"golang/globals"
	"golang/handlers"
	initfuncs "golang/init"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/joho/godotenv"
)

func TestLoginHandler(t *testing.T) {
	// Seed DB with a fake user first
	godotenv.Load("../../.env")

	initfuncs.Database()

	_, err := globals.Globaldb.Exec(`
    INSERT INTO people (admn_hash, name, social, socialtype, roomno, blockno)
    VALUES ($1, $2, $3, $4, $5, $6)
`, "69", "Shaurya", "discordusername", "Discord", 123, 16)

	if err != nil {
		t.Fatalf("insert failed: %v", err)
	}

	req := httptest.NewRequest("GET", "/login?admn_hash=69&name=Shaurya", nil)
	w := httptest.NewRecorder()

	handlers.Login(w, req)

	resp := w.Result()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if w.Body.String() == "not found" {
		t.Errorf("expected token, got %q", w.Body.String())
	}

	// cleanup
	globals.Globaldb.Exec("DELETE FROM people WHERE admn_hash='69'")
	globals.Globaldb.Exec("DELETE FROM sessions WHERE admnno='69'")
}

Logout Handler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func TestLogoutHandler(t *testing.T) {
	godotenv.Load("../../.env")

	initfuncs.Database()
	_, err := globals.Globaldb.Exec(`
        INSERT INTO sessions (id, admnno) VALUES ($1, $2)
    `, "a18fbd57f9bbfd0450659cb69333415f", "69")
	if err != nil {
		t.Fatalf("failed to insert session: %v", err)
	}

	req := httptest.NewRequest("GET", "/logout", nil)
	req.AddCookie(&http.Cookie{Name: "sess_id", Value: "a18fbd57f9bbfd0450659cb69333415f"})
	w := httptest.NewRecorder()

	handlers.Logout(w, req)

	resp := w.Result()
	if resp.StatusCode != http.StatusOK {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
	if w.Body.String() != "logged out" {
		t.Errorf("expected 'logged out', got %q", w.Body.String())
	}

	// cleanup
	globals.Globaldb.Exec("DELETE FROM sessions WHERE admnno=$1", "69")
}

Verify handler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func TestVerifyHandler(t *testing.T) {
	// Load env + init DB
	if err := godotenv.Load("../../.env"); err != nil {
		t.Fatalf("failed to load .env: %v", err)
	}
	initfuncs.Database()

	// Seed DB with a fake session
	fakeToken := "a18fbd57f9bbfd0450659cb69333415f"
	_, err := globals.Globaldb.Exec(`
        INSERT INTO sessions (id, admnno, expires_at)
        VALUES ($1, $2, NOW() + interval '1 hour')
    `, fakeToken, "69")
	if err != nil {
		t.Fatalf("failed to insert session: %v", err)
	}

	// Make request with cookie
	req := httptest.NewRequest("GET", "/verify", nil)
	req.AddCookie(&http.Cookie{Name: "sess_id", Value: fakeToken})
	w := httptest.NewRecorder()

	handlers.Verify(w, req)

	resp := w.Result()
	if resp.StatusCode != http.StatusOK {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
	if w.Body.String() != "valid" {
		t.Errorf("expected 'valid', got %q", w.Body.String())
	}

	// Cleanup
	if _, err := globals.Globaldb.Exec("DELETE FROM sessions WHERE admnno=$1", "69"); err != nil {
		t.Logf("cleanup failed: %v", err)
	}
}

These are all the handlers we need to test separately - This is more of the better depth tests which allow me to narrow down the problems. The few times where my session handling failed, these tests helped me find the problem extremely fast and elegantly.

Although I did have to update the test at some point or another in the upgradation process, making them a bit of hindrance to my progress. But this is the price you pay for never having to go step by step for a whole day trying to figure out the problem with your code.

Benchmarking

So the main problem I was actively looking forward to solving was the optimization for handling /blocks and /rooms fetch api. Obviously they’re the most heavy duty workers, and they’re doing the most complex tasks.

I made a small script to benchtest our workers by seeding a test_db with 1000 fake users -

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import psycopg2
import random
import string
from datetime import datetime
from faker import Faker

fake = Faker()

def random_hash(length=8):
    return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))

def seed_users(n=1000):
    conn = psycopg2.connect(
        dbname="test_db",
        user="devuser",
        password="devpass",
        host="localhost",
        port="5432"
    )
    cur = conn.cursor()

    for i in range(n):
        admn_hash = random_hash(12)
        name = fake.name()
        social = fake.user_name()
        socialtype = random.choice(["Discord", "Twitter", "Instagram"])
        roomno = random.randint(1, 200)
        blockno = random.randint(1, 20)
        created_at = datetime.now()

        cur.execute("""
            INSERT INTO people (admn_hash, name, social, socialtype, roomno, blockno, created_at)
            VALUES (%s, %s, %s, %s, %s, %s, %s)
        """, (admn_hash, name, social, socialtype, roomno, blockno, created_at))

    conn.commit()
    cur.close()
    conn.close()
    print(f"Seeded {n} users into people table.")

if __name__ == "__main__":
    seed_users(1000)

So now we’ll go step by step in the optimizations I added.

1. Caching (for /blocks)

Refresh the page 1000 times, or give 1000 people 1 refresh… no difference.

So everyone knows the concept of caching. I made the caching mechanism myself as a good way to get a good grasp on caching and it’s use cases.

Now fortunately this would allow me to dwelve deeply into sync.Mutex Locking which I was looking forward to since gobyexample.com had taught me well enough to know when I should use it. Cache is a thing that can be edited and read by multiple handlers at the same time so it’ll always be need to be protected to prevent a race condition.

So my first approach was kind of a master-class in how NOT to approach Locking. This was partially due to the fact that i underestimated the bottleneck of JSON.Marshal that managed to overshadow our results.

1
2
3
4
5
6
7
8
if time.Now().After(globals.CacheExpiry) {
	caching.CacheUpdate() // Cache Update
}
globals.CacheMutex.RLock()
str, err := json.Marshal(globals.CacheBlocks) // Retrieve the cache
globals.CacheMutex.RUnlock()

1
2
3
4
5
6
7
8
9
10
11
func CacheUpdate(){
	// .. boring sql stuff to retrieve stuff from db to update our cache

	globals.CacheMutex.Lock()
	clear(globals.CacheBlocks) // clear cache
	globals.CacheBlocks = Block
	globals.CacheExpiry = time.Now().Add(30 * time.Second)
	globals.CacheMutex.Unlock()

	// ..exit 
}

You see this is not a good idea when you check benchmarks - time for cache fetching went UP instead because of the sheer amount of locking we started doing for reading, added on with our bottleneck that i discovered later to be JSON.Marshal

Environment

  • OS: Linux
  • Arch: amd64
  • CPU: Intel(R) Core(TM) i7-9750H @ 2.60GHz
  • Go pkg: golang/benchmarks/simplefetch

Without Caching

BenchmarkIterationsTime/opBytes/opAllocs/op
BlocksHandler2,877388,047 ns258,5462,207
RoomsBlocksHandler1,000,000,0000.0009717 ns00

With Caching

BenchmarkIterationsTime/opBytes/opAllocs/op
BlocksHandler2854,649,916 ns17,629,019459,798
RoomsBlocksHandler1,000,000,0000.02232 ns00

Infact the first time i tried caching, the ns/op went up, although the time was still lower than going for a fetch.

The JSON.Marshal optimization

Turns out, JSONifying a dictionary is not worth it when you have to do it everytime you recall a cache hit. That defeats the point of a cache hit, you can save on time by just saving the JSON string in the Cache instead. That’s where i realised “Cache is a very flexible idea and applied in many ways, not just objects”

1
2
3
4
5
6
if time.Now().After(globals.CacheExpiry) {
	caching.CacheUpdate()
}
globals.CacheMutex.RLock()
str, err := json.Marshal(globals.CacheBlocks) // We have to Marshal it everytime we retrieve the Cache (Bottleneck)
globals.CacheMutex.RUnlock()

TLDR; Turns out instead of doing

DB -> Cache -> JSON.Marshal(result) -> client

We shorten the run so the result is already marshalled so the end run becomes

DB -> Cache -> client

So the code becomes

1
2
3
4
5
6
7
func CacheUpdate(){
	// ...
	globals.CacheMutex.Lock()
	bytes, err := json.Marshal(Block)
	globals.CachedBlocksJSON = string(bytes) // store it stringified, maximum preprocessing
	globals.CacheMutex.Unlock()
	//...

And then the thing took off like a rocket:

Removed json.Marshal overhead (caching)

BenchmarkIterationsTime/opBytes/opAllocs/op
BlocksHandler3780287,733 ns208,3791723
RoomsBlocksHandler1,000,000,0000.0003799 ns00

This number… 0.0003799 ns this is the kind of latency i expected from Go.

Honestly felt kind of good about myself after that.

2. Worker Pools + Caching for /rooms

The most interesting part about /rooms is that each block number needs to have their own cache. This cache as we discussed earlier need be JSON.Marshal, but there was also something i noticed very early on while implementing caching - we can have multiple workers update the cache of different blocks.

Each block essentially acts as an independent cacher by itself so we keep track of those expiries in a dictionary.

Now the general case for our caching rooms looked like this

1
2
3
4
5
6
7
8
9
// For caching rooms : block -> room number -> structs.Room
var CacheRooms map[string]map[string]*structs.Room = make(map[string]map[string]*structs.Room, 0)
var CachedRoomsJSON map[string]string = make(map[string]string, 0)

// Per block expiry time
var CacheBlocksExpiry map[string]time.Time = make(map[string]time.Time, 0)
var CacheRoomsMutex sync.RWMutex

The results of caching for /rooms wasn’t very interesting - partially because they were small fetched results and i couldn’t really figure out a way to stress test cacheing without a meaningful difference in the time saved.

So anyways onto the WorkerPool implementation -

1
2
3
4
5
6
7
8
9
10
package structs

type RoomsJob struct { 
	Blockno string
}

type RoomsJobResult struct {
	Blockno string
	JSON    string
}

The essential de-mystified idea of having workers is having jobs to assign them to, so they can achieve tasks parallel-y. Now my initial bottleneck with doing workerpools was that there was VIRTUALLY NO DIFFERENCE IN THE TIME SAVE.

My initial idea was to have a worker that is given a Job to update the cache of a certain ‘block number’, and it would straight away update the cache when it was done generating the result from the DB.

The same sync Locks that it required to ensure that the cache data was safeguarded from a race condition - made it so that concurrent instances of workers had to actually line up enough to bottleneck on a single worker’s work. So it essentially became a queue where the workers had to still sit in line and not pick up new jobs after being done with their previous ones.

Pretty crazy right?

Fan-in/Fan-out implementation

Give jobs, take results, only one goroutine is allowed to look at the results

This is a fucking amazing optimization right here - basically when our workers are done with a job, they no longer have to wait for anything to update the cache. They pass it to a channel called results.

The results channel is now taken care of by only ONE goroutine, so that means, severely less locking, and incredible cache refreshing times.

1
2
3
4
5
6
7
8
9
10
11
12
func WorkersResults() {
	for result := range globals.CacheRoomsJobsResults {
		logrus.WithFields(logrus.Fields{
			"package":       "routine",
			"resultblockno": result.Blockno,
			"resultlen":     len(result.JSON),
		}).Debug("updating cache with result from worker")
		globals.CachedRoomsJSON.Store(result.Blockno, result.JSON)
		//<-globals.CacheRoomsJobsResults
	}
}

This is the even more insane part - Go comes with it’s own library for sync Maps that handle locking and unlocking for you. In fact, they’re well built to accomodate it.

So now our entire code has become

1
2
3
4
5
6
7
8
9
10
11
// in func main()
for range globals.NumWorkers { // we started all our workers for infinite time.
		globals.CacheRoomsJobsWaitGroup.Go(func() {
			for job := range globals.CacheRoomsJobs {
				caching.CacheRoomsUpdate(job.Blockno)
			}
		})
	}

go goroutines.WorkersResults() // started our result cleanup at the same time

1
2
3
4
// Cache update trigger
func AddCacheRoomsJob(blockno string) {
	globals.CacheRoomsJobs <- structs.RoomsJob{Blockno: blockno} // updating cache now means just adding a job to queue
}
1
2
3
4
5
6
7
8
9
// Cache update result
rooms := FormRooms(blockno)

bytes, _ := json.Marshal(rooms)

globals.CacheRoomsJobsResults <- structs.RoomsJobResult{Blockno: blockno, JSON: string(bytes)}

globals.CacheBlocksExpiry.Store(blockno, time.Now().Add(globals.CacheRoomsSeconds*time.Second))

So overall our entire benchmark has dropped.. let us compare the worker wise distributions

const NumWorkers = 1

goos: linux goarch: amd64 pkg: golang/benchmarks/simplefetch cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkRoomsBlocksHandler-12 2705 req/s 13353 B/op 124 allocs/op PASS ok golang/benchmarks/simplefetch 28.848s

const NumWorkers = 50 goos: linux goarch: amd64 pkg: golang/benchmarks/simplefetch cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkRoomsBlocksHandler-12 25360 req/s 85 allocs/op PASS ok golang/benchmarks/simplefetch 14.656s

Anything beyond 50 the workers start to crowd, it becomes like that old saying

Too many chefs spoil the broth

Cryptography

Nothing special here but an honourable mention to our backend and frontend hashing

1
2
admnno := q.Get("admn_hash")
admn_hash := globals.SecureHash(admnno, os.Getenv("PEPPER"))
1
2
3
4
5
6
7
8
9
10
const encoder = new TextEncoder();
const data = encoder.encode(admnno); // concat admnno
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");

const queryParams = new URLSearchParams({
	admn_hash: hashHex,
	name: name
});

Dockerization & Deployment

So for the sake of it i learnt enough docker to launch containers for my db, go backend and frontend.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
services:
  postgres:
    image: postgres:16
    container_name: pg-roommate
    restart: always
    environment:
      POSTGRES_USER: devuser
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: roommatefinder
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

  golang:
    build: ./golang
    container_name: go-backend
    restart: always
    environment:
      DATABASE_URL: postgres://devuser:devpass@postgres:5432/roommatefinder?sslmode=disable
      LOG_FORMAT: text
      LOG_LEVEL: debug
      CACHING: true
      PEPPER: prabhansharularegaytogether
    ports:
      - "8080:8080"
    depends_on:
      - postgres
      
  roomate-finder:
    build: ./roomate-finder
    container_name: nextjs-frontend
    restart: always
    command: npm run dev
    environment:
      NEXT_PUBLIC_API_URL: http://localhost:8080
      SERVER_API_URL: http://go-backend:8080
    ports:
      - "3000:3000"
    depends_on:
      - golang
    volumes:
      - ./roomate-finder:/app 
      - /app/node_modules

volumes:
  pgdata:

Most learning the fact that nextjs is NOT easy to export.

My Postgres Instance right now is hosted on Neon, Go backend is on Render, and Next-JS is on Vercel. Overall i had 0 difficulties even trying to deploy, it seems like theyre almost built for fullstack apps like mine, but honestly i kind of wish i hadnt spread the workload between 3 different services. I would’ve liked to have my docker compose up and running if i had access to spin up some docker containers 24/7.

Conclusion

yeah this project was dope lol

if anything i’m definitely going to try to implement sharding on my own or even cache sharding.

but for now i think this is enough.

alright bye.

This post is licensed under CC BY 4.0 by the author.