Skip to content

Commit 8f66de9

Browse files
committed
feat: add blog feature with live view and markdown rendering
- Implemented BlogLive module for displaying blog posts and individual post details. - Added routes for blog index and show actions in the router. - Introduced render_unsafe function in Markdown module for HTML conversion without sanitization. - Created initial blog post markdown file for "Building a globally distributed live-streaming app".
1 parent 1e3657b commit 8f66de9

File tree

4 files changed

+376
-0
lines changed

4 files changed

+376
-0
lines changed

lib/algora/shared/markdown.ex

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,17 @@ defmodule Algora.Markdown do
4040
md_or_doc
4141
end
4242
end
43+
44+
def render_unsafe(md_or_doc, opts \\ []) do
45+
default_opts = update_in(@default_opts, [:features, :sanitize], fn _ -> false end)
46+
47+
case MDEx.to_html(md_or_doc, Keyword.merge(default_opts, opts)) do
48+
{:ok, html} ->
49+
html
50+
51+
{:error, error} ->
52+
Logger.error("Error converting markdown to html: #{inspect(error)}")
53+
md_or_doc
54+
end
55+
end
4356
end

lib/algora_web/live/blog_live.ex

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
defmodule AlgoraWeb.BlogLive do
2+
@moduledoc false
3+
use AlgoraWeb, :live_view
4+
5+
alias Algora.Markdown
6+
alias AlgoraWeb.Components.Footer
7+
alias AlgoraWeb.Components.Header
8+
9+
@blog_path "priv/blog"
10+
11+
@impl true
12+
def mount(%{"slug" => slug}, _session, socket) do
13+
case load_blog_post(slug) do
14+
{:ok, {frontmatter, content}} ->
15+
{:ok,
16+
assign(socket,
17+
content: Markdown.render_unsafe(content),
18+
frontmatter: frontmatter,
19+
page_title: frontmatter["title"]
20+
)}
21+
22+
{:error, _reason} ->
23+
{:ok, push_navigate(socket, to: ~p"/blog")}
24+
end
25+
end
26+
27+
@impl true
28+
def mount(_params, _session, socket) do
29+
posts = list_blog_posts()
30+
{:ok, assign(socket, posts: posts, page_title: "Blog")}
31+
end
32+
33+
@impl true
34+
def render(assigns) do
35+
~H"""
36+
<div>
37+
<Header.header />
38+
<div class="max-w-5xl mx-auto px-4 pt-32 pb-16 sm:pb-24">
39+
<%= if @live_action == :index do %>
40+
<h1 class="text-3xl font-bold mb-8">Blog Posts</h1>
41+
<div class="space-y-6">
42+
<%= for post <- @posts do %>
43+
<div class="border border-border p-6 rounded-lg hover:border-border/80">
44+
<.link navigate={~p"/blog/#{post.slug}"} class="block space-y-2">
45+
<h2 class="text-3xl font-bold hover:text-success font-display">
46+
{post.title}
47+
</h2>
48+
<div class="flex items-center gap-4 text-sm text-muted-foreground">
49+
<time datetime={post.date}>{format_date(post.date)}</time>
50+
<div class="flex items-center gap-2">
51+
<%= for author <- post.authors do %>
52+
<img src={"https://github.com/#{author}.png"} class="w-6 h-6 rounded-full" />
53+
<% end %>
54+
</div>
55+
</div>
56+
<div class="flex flex-wrap gap-2 mt-2">
57+
<%= for tag <- post.tags do %>
58+
<.badge>
59+
{tag}
60+
</.badge>
61+
<% end %>
62+
</div>
63+
</.link>
64+
</div>
65+
<% end %>
66+
</div>
67+
<% else %>
68+
<article class="prose dark:prose-invert max-w-none">
69+
<header class="mb-8 not-prose">
70+
<h1 class="text-5xl font-display font-extrabold tracking-tight mb-4 bg-clip-text text-transparent bg-gradient-to-r from-emerald-400 to-emerald-300">
71+
{@frontmatter["title"]}
72+
</h1>
73+
74+
<div class="flex items-center gap-4 text-muted-foreground mb-4">
75+
<time datetime={@frontmatter["date"]}>{format_date(@frontmatter["date"])}</time>
76+
<div class="flex items-center gap-3">
77+
<%= for author <- @frontmatter["authors"] do %>
78+
<div class="flex items-center gap-2">
79+
<img src={"https://github.com/#{author}.png"} class="w-8 h-8 rounded-full" />
80+
<.link
81+
href={"https://github.com/#{author}"}
82+
target="_blank"
83+
class="text-sm hover:text-muted-foreground/80"
84+
>
85+
@{author}
86+
</.link>
87+
</div>
88+
<% end %>
89+
</div>
90+
</div>
91+
92+
<div class="flex flex-wrap gap-2">
93+
<%= for tag <- @frontmatter["tags"] do %>
94+
<.badge>
95+
{tag}
96+
</.badge>
97+
<% end %>
98+
</div>
99+
</header>
100+
101+
{raw(@content)}
102+
</article>
103+
<% end %>
104+
</div>
105+
<Footer.footer />
106+
</div>
107+
"""
108+
end
109+
110+
defp load_blog_post(slug) do
111+
with {:ok, content} <- File.read(Path.join(@blog_path, "#{slug}.md")),
112+
[frontmatter, markdown] <- content |> String.split("---\n", parts: 3) |> Enum.drop(1),
113+
{:ok, parsed_frontmatter} <- YamlElixir.read_from_string(frontmatter) do
114+
{:ok, {parsed_frontmatter, markdown}}
115+
end
116+
end
117+
118+
defp list_blog_posts do
119+
@blog_path
120+
|> File.ls!()
121+
|> Enum.filter(&String.ends_with?(&1, ".md"))
122+
|> Enum.map(fn filename ->
123+
{:ok, {frontmatter, _content}} = load_blog_post(String.replace(filename, ".md", ""))
124+
125+
%{
126+
slug: String.replace(filename, ".md", ""),
127+
title: frontmatter["title"],
128+
date: frontmatter["date"],
129+
tags: frontmatter["tags"],
130+
authors: frontmatter["authors"]
131+
}
132+
end)
133+
|> Enum.sort_by(& &1.date, :desc)
134+
end
135+
136+
# Add this function to format dates
137+
def format_date(date_string) when is_binary(date_string) do
138+
case Date.from_iso8601(date_string) do
139+
{:ok, date} -> Calendar.strftime(date, "%B %d, %Y")
140+
_ -> date_string
141+
end
142+
end
143+
144+
def format_date(_), do: ""
145+
end

lib/algora_web/router.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ defmodule AlgoraWeb.Router do
138138
live "/onboarding/dev", Onboarding.DevLive
139139
live "/pricing", PricingLive
140140
live "/swift", SwiftBountiesLive
141+
live "/blog/:slug", BlogLive, :show
142+
live "/blog", BlogLive, :index
141143
end
142144

143145
live_session :root,
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
---
2+
title: "Building a globally distributed live-streaming app for developers with Elixir & Tigris"
3+
date: "2024-03-20"
4+
tags:
5+
[
6+
"opensource",
7+
"livestreaming",
8+
"video sharing",
9+
"tv",
10+
"elixir",
11+
"tigris",
12+
"postgresql",
13+
]
14+
draft: false
15+
summary: ""
16+
authors: ["zcesur"]
17+
---
18+
19+
<div className="flex items-center gap-2">
20+
<span className="font-semibold text-white">📺 Website:</span>
21+
<a style={{ textDecorationLine: 'none' }} href="https://tv.algora.io">
22+
tv.algora.io
23+
</a>
24+
</div>
25+
26+
<div className="flex items-center gap-2">
27+
<span className="font-semibold text-white">⭐ Repository:</span>
28+
<a style={{ textDecorationLine: 'none' }} href="https://github.com/algora-io/tv">
29+
github.com/algora-io/tv
30+
</a>
31+
</div>
32+
33+
---
34+
35+
## Introduction
36+
37+
I love solutions that give you 90% of the benefits with only 10% of the effort.
38+
39+
![](/static/images/post/90-10.png)
40+
41+
So, in this new project, I decided to take on a radically simple approach, where I would use the fewest number of tools to build a globally distributed live-streaming app. To do that, I disected the app into 3 orthogonal layers: 1) compute, 2) object storage, and 3) database, and used the most powerful tool that requires the least amount of effort to handle each.
42+
43+
## Compute: Elixir
44+
45+
What if I told you that you don't have to use a SPA framework like React or Vue to build highly interactive web applications? I took precisely that approach for this project and decided to implement everything in Elixir with a tiny bit of JS sprinkled here and there.
46+
47+
### Phoenix LiveView
48+
49+
Phoenix LiveView is a super unique framework for Elixir that enables rich, real-time user experiences with server-rendered HTML. From their docs:
50+
51+
- **Declarative rendering:** Render HTML on the server over WebSockets with a declarative model.
52+
53+
- **Diffs over the wire:** Instead of sending "HTML over the wire", LiveView knows precisely which parts of your templates change, sending minimal diffs over the wire after the initial render, reducing latency and bandwidth usage. The client leverages this information and optimizes the browser with 5-10x faster updates compared to solutions that replace whole HTML fragments.
54+
55+
In addition, Elixir is a match made in heaven for any compute needs of this project due to its unmatched concurrency model & distribution primitives. There's a famous saying in computer science which goes like
56+
57+
> Any sufficiently complicated concurrent program in another language contains an ad hoc informally-specified bug-ridden slow implementation of half of Erlang.
58+
59+
| Technical requirement | Server A | Server B |
60+
| ---------------------- | ---------------------------- | -------- |
61+
| HTTP server | Nginx | Erlang |
62+
| Request processing | Ruby on Rails | Erlang |
63+
| Long-running requests | Go | Erlang |
64+
| Server-wide state | Redis | Erlang |
65+
| Persistable data | Redis and MongoDB | Erlang |
66+
| Background jobs | Cron, Bash scripts, and Ruby | Erlang |
67+
| Service crash recovery | Upstart | Erlang |
68+
69+
### Multimedia processing
70+
71+
Finally, Elixir has a fantastic ecosystem around multimedia processing via the [Membrane Framework](https://hexdocs.pm/membrane_core/readme.html), which we leveraged to implement our live-streaming pipeline.
72+
73+
![](/static/images/post/membrane-pipeline.png)
74+
75+
```elixir
76+
defmodule Pipeline do
77+
def handle_init(_context, socket: socket) do
78+
video = Library.init_livestream!()
79+
80+
spec = [
81+
# audio
82+
child(:src, %Membrane.RTMP.SourceBin{
83+
socket: socket,
84+
validator: %Algora.MessageValidator{video_id: video.id}
85+
})
86+
|> via_out(:audio)
87+
|> via_in(Pad.ref(:input, :audio),
88+
options: [encoding: :AAC, segment_duration: Membrane.Time.seconds(2)]
89+
)
90+
|> child(:sink, %Membrane.HTTPAdaptiveStream.SinkBin{
91+
mode: :live,
92+
manifest_module: Membrane.HTTPAdaptiveStream.HLS,
93+
target_window_duration: :infinity,
94+
persist?: false,
95+
storage: %Algora.Storage{video: video}
96+
}),
97+
98+
# video
99+
get_child(:src)
100+
|> via_out(:video)
101+
|> via_in(Pad.ref(:input, :video),
102+
options: [encoding: :H264, segment_duration: Membrane.Time.seconds(2)]
103+
)
104+
|> get_child(:sink)
105+
]
106+
107+
{[spec: spec], %{socket: socket, video: video}}
108+
end
109+
end
110+
```
111+
112+
## Object storage: Tigris
113+
114+
Fly.io recently introduced object storage on their infra through their partnership with [Tigris](https://www.tigrisdata.com), a team that built and operated Uber's global storage platform.
115+
116+
We met the CEO of Tigris, Ovais Tariq, about a year ago on our podcast, so we were able to get access to their private beta. The service isn't fully battle-tested yet, but the Tigris API is S3 compatible, so we could always migrate to another S3-compatible service if need be.
117+
118+
### Storage module
119+
120+
Adding a storage module was super easy. The function responsible for sending streams to Tigris was essentially three lines of code.
121+
122+
```elixir
123+
def upload_file(path, contents, opts \\ []) do
124+
Algora.config([:files, :bucket])
125+
|> ExAws.S3.put_object(path, contents, opts)
126+
|> ExAws.request([])
127+
end
128+
```
129+
130+
Behind the scenes, Tigris takes care of distributing the video segments to multiple geographical locations (based on access patterns) and caches them to provide low latency reads. In addition, they automatically deliver strong read-after-write consistency and durability with the globally distributed metadata layer they built on top of FoundationDB.
131+
132+
### Tigris API
133+
134+
Tools that are magical usually come at the cost of not being easy to tweak, but so far, I have found Tigris to be super flexible. As an example, we had to make sure our `.m3u8` playlists did not get cached while the stream was still running live, and implementing that was as easy as adding another one-liner:
135+
136+
```elixir
137+
defp upload_opts(%{type: :manifest} = _ctx) do
138+
[{:cache_control, "no-cache, no-store, private"}]
139+
end
140+
```
141+
142+
On the flip side, Tigris also allows you to eagerly cache objects on write in other regions. This might be useful in the future if we know that, say, a streamer in LA is often watched by viewers in India so that we can immediately distribute & cache their segments there.
143+
144+
All in all, with Tigris, we didn't have to worry about adding a separate CDN or a 3rd party video streaming service. We just pushed video segments to our bucket using the familiar S3 API, and our viewers streamed the videos directly from Tigris while having the flexibility to tweak it to our needs in the future.
145+
146+
## Database: PostgreSQL
147+
148+
When it comes to databases, I have a simple rule in life that has served me well so far: use Postgres unless you have a clear reason not to. Of course, something like CockroachDB or ScyllaDB would've been solid choices too, but they'd be a total overkill for an app with less than 100 users.
149+
150+
So we decided to use Postgres to store everything except for the media files, which include things like channel information, video metadata, chat messages, transcripts, etc. We then added read replicas to ensure everyone worldwide can access these with low latency with very little effort from our end.
151+
152+
Now, it's true that if you're just opening the app, you might see a snapshot of the past (e.g., you might not see the messages or live streams in the last few seconds) since you're reading from the nearest read replica. However, as long as you stay online, you will get all events in real-time through websockets powered by [Phoenix PubSub](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html), so in my opinion, this is the perfect trade-off. Plus if we need strong consistency for some reads, we could always reroute a query to the primary database without much effort.
153+
154+
On the other hand, data writes are much trickier because they have to go to the primary. This presents two big challenges: 1) read-your-writes consistency and 2) round-trip latency.
155+
156+
### Read-your-writes consistency
157+
158+
Consider the following function for a minute. Do you see any problems?
159+
160+
```elixir
161+
def handle_event("save", %{"user" => params}, socket) do
162+
:ok = Accounts.update_settings(socket.assigns.current_user, params)
163+
{:ok, user} = Accounts.get_user(socket.assigns.current_user.id)
164+
{:noreply, socket |> assign(current_user: user)}
165+
end
166+
```
167+
168+
`Accounts.update_settings` mutates the primary database, whereas `Accounts.get_user` reads from the replica. There's no guarantee that the updated settings will be replicated to the replica by the time we fetch the user, so in all likelihood, we will return stale data to the user.
169+
170+
Of course, this is a naive example, and it looks rather obvious when it's 3 lines of code, but this sort of stuff can easily sneak into our codebase as our business logic gets increasingly complex.
171+
172+
What's the solution? Modify your [`Ecto.Repo`](https://hexdocs.pm/ecto/Ecto.Repo.html) so that all insert/update/delete operations block until the changes are replicated before we read from our replica. [`Fly.Postgres`](https://hexdocs.pm/fly_postgres/Fly.Postgres.html) does this out of the box by [tracking the LSN (Log Sequence Number)](https://hexdocs.pm/fly_postgres/Fly.Postgres.LSN.Tracker.html#content) on the Postgres WAL (Write Ahead Log), but if you're not using [Fly.io](https://fly.io), you could also implement this yourself.
173+
174+
### Round-trip delays
175+
176+
Another problem is the delays due to round-trips between our primary and our server. Imagine we have a naive function that receives a JSON array of time-stamped video subtitles from a user and inserts each of them one by one into our database:
177+
178+
```elixir
179+
defmodule Library do
180+
def save_subtitle(sub) do
181+
%Subtitle{id: sub["id"]}
182+
|> Subtitle.changeset(%{start: sub["start"], end: sub["end"], body: sub["body"]})
183+
|> Repo.update!()
184+
end
185+
186+
def save_subtitles(data) do
187+
Jason.decode!(data) |> Enum.map(&save_subtitle/1)
188+
end
189+
end
190+
```
191+
192+
With our current setup, this function would make $N$ calls to the primary database and wait $N$ times for replication in between each call:
193+
194+
```elixir
195+
defp save("naive", subtitles) do
196+
Library.save_subtitles(subtitles)
197+
end
198+
```
199+
200+
Solution: just RPC to the primary server and complete all database changes there.
201+
202+
```elixir
203+
defp save("fast", subtitles) do
204+
Fly.Postgres.rpc_and_wait(Library, :save_subtitles, [subtitles])
205+
end
206+
```
207+
208+
The code remains pretty much the same, but with this method we make a single round-trip with the primary database. Pretty neat!
209+
210+
## Conclusion
211+
212+
Constraining myself to a limited number of tools has proved to be super useful as this is the most productive I've ever felt in my life! I was able to finish the MVP of the app within a few weeks with only a shallow knowledge of Elixir beforehand.
213+
214+
In the next blogpost we'll add some AI features like generating transcripts from streams using pre-trained Neural Network models in [Bumblebee](https://github.com/elixir-nx/bumblebee) (an Elixir library for machine learning), and store the artifacts in our [Tigris](https://www.tigrisdata.com) bucket.
215+
216+
See you in the next episode!

0 commit comments

Comments
 (0)