Computer Science Simplified

Computer Science Simplified

Designing and building LeetCode - Part III

Martin Joo's avatar
Martin Joo
Feb 11, 2025
∙ Paid
12
2
Share

Introduction

Around September 2024 I built a LeetCode alternative. My plan was to create better, more practical problems/challenges and launch it as a small product.

It had a promising start, but eventually I shut it down because it had 0 paying customers, and the system was a bit too complex to maintain it as a free app.

At least now, I can share how it works because it has some interesting stuff:

  • Remote code execution

  • Docker container pooling

  • Asserting user submissions

Get the book

I also put together a 72-page book, that contains more knowledge and the whole 13k line codebase to the project.

  • High-level design

  • Docker inside Docker

  • Container pools

  • Code execution

  • Asserting test cases

  • Init and tear down tasks for containers

  • Package oriented design

  • How to navigate the source code

Check out the book

Code execution

Now that we have a container pool it’s time for code execution.

There’s an executor struct:

type Executor struct {
  containerPool *ContainerPool
  config        *config.Config
  logger        *zap.Logger
}

As you can see, it contains a container pool instance. The only important function on this type is Execute:

func (e *Executor) Execute(
  ctx context.Context, 
  sub *submission.Submission, 
  lang *language.Language
) (r *output.Result, retErr error) {
  if sub.StatusId >= submission.TestsPassed {
    return nil, fmt.Errorf(
      "submission has already been executed: %v", sub
    )
  }

  id, err := e.containerPool.Get(lang)
  if err != nil {
    return nil, fmt.Errorf(
      "unable to get container (%s): %w", lang.ID, err
    )
  }
  defer func() {
    pushErr := e.containerPool.PushBack(lang, id)
    if pushErr != nil {
      retErr = pushErr
    }
  }()
}

Code execution needs a running container so the first is grabbing one from the pool. When the Execute function returns (either with a result or an error) the ID is pushed back to the pool by the deferred function.

The Execute function accepts a Submission argument. This type represents the user’s submission and it has fields like these:

type Submission struct {
  ID 		    string
  SourceCodeDecoded string
  SourceCodeEncoded string
  LanguageId        string
  ProblemId         string
  UserID            string

  StdoutRaw   	    string
  Stderr      	    string
  StdoutLines 	    []string
  StatusId    	    int
  SystemErr         string
}

The first set of fields (from ID to UserID) are available when the user hits the submit button. Such as which programming language the user used, what’s the problem ID the user is trying to solve, etc.

The second set of fields (from StdoutRaw to SystemErr) are the result of the execution. Such as what’s the output, any error that happened, and a status (passed, failed, etc).

The fact that we can differentiate between these two sets of fields is a good indicator that there probably should be two different types:

  • Submission

  • SubmissionResult

However, I store all of these in one table so it was just easier to keep them together. Also, it’s not a huge object (or a huge table) so it doesn’t cause any problems yet. I often do this, by the way. The first version should be as simple as possible. As time goes on and features grow in complexity, then it’s time to refactor things like this.

So we have a container ID and the submission. They often go together since every submission will have a running container so I created another type called Container:

type Container struct {
  ID           string
  languageID   string
  problemID    string
  submissionID string
}

It’s really just a container for a submission and a container ID and implements only a few basic functions that we’ll see soon.

In the Execute function, I create a container which we’ll use later:

c := container.NewContainer(id, sub.LanguageId, sub.ProblemId, sub.ID)

So now we have the source as a string and a running container. Without the context of LeetCode, just think about how would you the code into the container?

For me, this seems the most simple way:

echo "source code here" >> solution.php
docker cp cc5564edfe81:/usr/solution.php solution.php

So we simply create a file and copy it to the container.

And then the execution would be something like that:

docker exec cc5564edfe81 php /usr/solution.php

The docker CLI offers a function:

CopyToContainer()

It takes a few arguments, but the most important is content. However, we cannot copy a php file into a container. The file has to be a TAR archive. Even if it only contains a single file.

I/O in Go

More specifically, CopyToContainer takes an io.Reader for a TAR archive. As we discussed in the last part, when it comes I/O operations, everything is an io.Reader or io.Writer in Go.

So let’s understand them first.

They are interfaces with a single function:

writer.Write(p []byte) (n int, err error)
reader.Read(p []byte) (n int, err error)

They both have the same signature which seems weird at first.

Write takes a byte array which contains the content we want to write. It returns an integer that contains how many bytes were successfuly writter. If everything went well then the length of the input should be equal to the return value:

len(p) == n

It also returns an error if something went wrong.

Read also takes a byte array and also returns an integer. Coming from PHP the logical signature would look like this:

reader.Read() (string, error)

Which is based on fread:

fread(resource $stream, int $length): string

However, PHP is “cheating” here. The original fread function from C looks like this:

fread(buffer, 1, BUFFER_SIZE, file)

The first argument, buffer, is a pointer where the read data will be stored. The same thing happens in Go:

buf := make([]byte, 1024)
reader.Read(buf)

Read() will read 1024 bytes into the buf array.

Usually, Go or C-like reading is more memory-efficient, has better performance, and offers more control since we control the buffer.

On the other, fread doesn’t require a dedicated paragraph to explain and PHP is able to have different signatures for reading and writing data:

fread(resource $stream, int $length): string
fwrite(resource $stream, string $data, ?int $length = null): int

So I/O looks like this in Go:

io.Reader and io.Writer are interfaces and they have hunderds of different implementations in the Go stdlib and the Go ecosystem in general.

Which gives developers an unmatched experience, becase everything (like literally, everything that has to do something with I/O) works the same:

  • Files

  • Buffered IO

  • TCP connection

  • HTTP requests

  • Databases

  • Arrays

  • Buffers

  • Your own array or buffer-like structures

  • Crypto (encription/decription)

  • Compression/decompression

  • Image processing

  • stdout, stdin

  • Streaming

  • Template rendering

  • IPC

Creating a TAR archive

Keep reading with a 7-day free trial

Subscribe to Computer Science Simplified to keep reading this post and get 7 days of free access to the full post archives.

Already a paid subscriber? Sign in
© 2025 Martin Joo
Privacy ∙ Terms ∙ Collection notice
Start writingGet the app
Substack is the home for great culture