Skip to content

Commit f284716

Browse files
authored
add migration (#148)
1 parent d7c8652 commit f284716

File tree

11 files changed

+477
-7
lines changed

11 files changed

+477
-7
lines changed

.tool-versions

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
elixir 1.13.2-otp-24
2-
erlang 24.2
1+
elixir 1.13.4-otp-25
2+
erlang 25.0.3
33
python 3.10.2

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## 0.9.3 (2022-10-14)
9+
* Bugfix
10+
* fix a bug in the hello handshake protocol (thanks to fireproofsocks for reporting)
11+
* Enhancements
12+
* add migration
13+
814
## 0.9.2 (2022-09-24)
915
* Bugfix
1016
* fix a crash in the streaming hello monitor, if the server sends more than one response at once

README.md

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -462,11 +462,129 @@ URI, as follows:
462462

463463
Using an SRV URI also discovers all nodes of the deployment automatically.
464464

465+
## Migration
466+
467+
Despite the schema-free approach, migration is still desirable. Migrations are used to maintain the indexes
468+
and to drop collections that are no longer needed. Capped collections must be migrated.
469+
The driver provides a workflow similar to Ecto that can be used to create migrations.
470+
471+
First we create a migration script:
472+
```elixir
473+
474+
mix mongo.gen.migration add_indexes
475+
476+
```
477+
478+
In `priv/mongo/migrations` you will find an Elixir script like `20220322173354_add_indexes.exs`:
479+
480+
```elixr
481+
defmodule Mongo.Migrations.AddIndexes do
482+
def up() do
483+
indexes = [
484+
[key: [email: 1], name: "email_index", unique: true]
485+
]
486+
487+
Mongo.create_indexes(:my_db, "my_collection", indexes)
488+
end
489+
490+
def down() do
491+
Mongo.drop_index(:my_db, "my_collection", "email_index")
492+
end
493+
end
494+
495+
```
496+
497+
After that you can run the migration using a task:
498+
499+
```
500+
mix mongo.migrate
501+
502+
🔒 migrations locked
503+
⚡️ Successfully migrated Elixir.Mongo.Migrations.CreateIndex
504+
🔓 migrations unlocked
505+
506+
```
507+
508+
Or let it run if your application starts:
509+
510+
```elixir
511+
defmodule MyApp.Release do
512+
@moduledoc """
513+
Used for executing DB release tasks when run in production without Mix
514+
installed.
515+
"""
516+
517+
def migrate() do
518+
Application.load(:my_app)
519+
Application.ensure_all_started(:ssl)
520+
Application.ensure_all_started(:mongodb_driver)
521+
Mongo.start_link(name: :mongo_db, url: "mongodb://localhost:27017/my-database", timeout: 60_000, pool_size: 1, idle_interval: 10_000)
522+
523+
Mongo.Migration.migrate()
524+
end
525+
end
526+
```
527+
528+
With the release features of Elixir you can add an overlay script like this:
529+
530+
```shell
531+
#!/bin/sh
532+
cd -P -- "$(dirname -- "$0")"
533+
exec ./my_app eval MyApp.Release.migrate
534+
```
535+
536+
```shell
537+
#!/bin/sh
538+
cd -P -- "$(dirname -- "$0")"
539+
PHX_SERVER=true exec ./my_app start
540+
```
541+
542+
And then you need just to call migrate before you start the server:
543+
544+
```shell
545+
/app/bin/migrate && /app/bin/server
546+
```
547+
548+
Or if you use a Dockerfile:
549+
550+
```dockerfile
551+
ENTRYPOINT /app/bin/migrate && /app/bin/server
552+
```
553+
554+
The migration module tries to *lock* the migration collection to ensure that only one instance is running the migration.
555+
Unfortunately MongoDB does not support collection locks, so need to use a software lock:
556+
557+
```elixir
558+
Mongo.update_one(topology,
559+
"migrations",
560+
%{_id: "lock", used: false},
561+
%{"$set": %{used: true}},
562+
upsert: true)
563+
```
564+
You can lock and unlock the migration collection using these functions in case of an error:
565+
566+
1. `Mongo.Migration.lock()`
567+
2. `Mongo.Migration.unlock()` or `mix mongo.unlock`
568+
569+
If nothing helps, just delete the document with `{_id: "lock"}` from the migration collection.
570+
571+
For more information see:
572+
573+
- `Mongo.Migration`
574+
- `Mix.Tasks.Mongo`
575+
- https://hexdocs.pm/mix/1.14/Mix.Tasks.Release.html
576+
465577
## Auth Mechanisms
466578

467579
For versions of Mongo 3.0 and greater, the auth mechanism defaults to SCRAM.
468-
If you'd like to use [MONGODB-X509](https://docs.mongodb.com/manual/tutorial/configure-x509-client-authentication/#authenticate-with-a-x-509-certificate)
469-
authentication, you can specify that as a `start_link` option.
580+
If you'd like to use [MONGODB-X509](https://www.mongodb.com/docs/v6.0/tutorial/configure-x509-client-authentication/)
581+
authentication, you can specify that as a `start_link` option.
582+
583+
You need roughly three additional configuration steps:
584+
585+
* Deploy with x.509 Authentication
586+
* Add x.509 Certificate subject as a User
587+
* Authenticate with an x.509 Certificate
470588

471589
```elixir
472590
{:ok, pid} = Mongo.start_link(database: "test", auth_mechanism: :x509)

config/config.exs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ config :logger, :console,
1515
metadata: [:module, :function, :line]
1616

1717
config :mongodb_driver,
18-
log: true
18+
log: true,
19+
migration: [
20+
path: "mongo/migrations",
21+
otp_app: :mongodb_driver,
22+
topology: :mongo,
23+
collection: "migrations"
24+
]
1925

2026
import_config "#{Mix.env()}.exs"

config/test.exs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,10 @@ config :mongodb_driver, Mongo.RepoTest.MyRepo,
55
show_sensitive_data_on_connection_error: true
66

77
config :mongodb_driver,
8-
log: false
8+
log: false,
9+
migration: [
10+
path: "mongo/migrations",
11+
otp_app: :mongodb_driver,
12+
topology: :mongo,
13+
collection: "migrations"
14+
]

lib/mongo/migration.ex

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
defmodule Mongo.Migration do
2+
@moduledoc false
3+
use Mongo.Collection
4+
5+
def migrate() do
6+
with :locked <- lock() do
7+
migration_files!()
8+
|> compile_migrations()
9+
|> Enum.each(fn {mod, version} -> run_up(version, mod) end)
10+
11+
unlock()
12+
end
13+
rescue
14+
_ ->
15+
unlock()
16+
end
17+
18+
def drop() do
19+
with :locked <- lock() do
20+
migration_files!()
21+
|> compile_migrations()
22+
|> Enum.reverse()
23+
|> Enum.each(fn {mod, version} -> run_down(version, mod) end)
24+
25+
unlock()
26+
end
27+
rescue
28+
_ ->
29+
unlock()
30+
end
31+
32+
def lock() do
33+
topology = get_config()[:topology]
34+
collection = get_config()[:collection]
35+
query = %{_id: "lock", used: false}
36+
set = %{"$set": %{used: true}}
37+
38+
case Mongo.update_one(topology, collection, query, set, upsert: true) do
39+
{:ok, %{modified_count: 1}} ->
40+
IO.puts("🔒 #{collection} locked")
41+
:locked
42+
43+
{:ok, %{upserted_ids: ["lock"]}} ->
44+
IO.puts("🔒 #{collection} locked")
45+
:locked
46+
47+
_other ->
48+
{:error, :already_locked}
49+
end
50+
end
51+
52+
def unlock() do
53+
topology = get_config()[:topology]
54+
collection = get_config()[:collection]
55+
query = %{_id: "lock", used: true}
56+
set = %{"$set": %{used: false}}
57+
58+
case Mongo.update_one(topology, collection, query, set) do
59+
{:ok, %{modified_count: 1}} ->
60+
IO.puts("🔓 #{collection} unlocked")
61+
:unlocked
62+
63+
_other ->
64+
{:error, :not_locked}
65+
end
66+
end
67+
68+
defp run_up(version, mod) do
69+
topology = get_config()[:topology]
70+
collection = get_config()[:collection]
71+
72+
case Mongo.find_one(topology, collection, %{version: version}) do
73+
nil ->
74+
mod.up()
75+
Mongo.insert_one(topology, collection, %{version: version})
76+
IO.puts("⚡️ Successfully migrated #{mod}")
77+
78+
_other ->
79+
:noop
80+
end
81+
end
82+
83+
defp run_down(version, mod) do
84+
topology = get_config()[:topology]
85+
collection = get_config()[:collection]
86+
87+
case Mongo.find_one(topology, collection, %{version: version}) do
88+
%{version: _version} ->
89+
mod.down()
90+
Mongo.delete_one(topology, collection, %{version: version})
91+
IO.puts("💥 Successfully dropped #{mod}")
92+
93+
_other ->
94+
:noop
95+
end
96+
end
97+
98+
def get_config() do
99+
defaults = [topology: :mongo, collection: "migrations", path: "mongo/migrations", otp_app: :mongodb_driver]
100+
Keyword.merge(defaults, Application.get_env(:mongodb_driver, :migration, []))
101+
end
102+
103+
def migration_file_path() do
104+
path = get_config()[:path]
105+
otp_app = get_config()[:otp_app]
106+
Path.join([:code.priv_dir(otp_app), path])
107+
end
108+
109+
defp migration_files!() do
110+
case File.ls(migration_file_path()) do
111+
{:ok, files} -> files
112+
{:error, _} -> raise "Could not find migrations file path"
113+
end
114+
end
115+
116+
defp compile_migrations(files) do
117+
Enum.map(files, fn file ->
118+
mod =
119+
(migration_file_path() <> "/" <> file)
120+
|> Code.compile_file()
121+
|> Enum.map(&elem(&1, 0))
122+
|> List.first()
123+
124+
version =
125+
~r/[0-9]/
126+
|> Regex.scan(file)
127+
|> Enum.join()
128+
|> String.to_integer()
129+
130+
{mod, version}
131+
end)
132+
end
133+
end

lib/tasks/gen/migration.ex

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
defmodule Mix.Tasks.Mongo.Gen.Migration do
2+
@moduledoc false
3+
4+
use Mix.Task
5+
6+
import Macro, only: [camelize: 1, underscore: 1]
7+
import Mix.Generator
8+
9+
@shortdoc "Generates a new migration for Mongo"
10+
11+
@spec run([String.t()]) :: integer()
12+
def run(args) do
13+
migrations_path = migration_file_path()
14+
name = List.first(args)
15+
base_name = "#{underscore(name)}.exs"
16+
current_timestamp = timestamp()
17+
file = Path.join(migrations_path, "#{current_timestamp}_#{base_name}")
18+
unless File.dir?(migrations_path), do: create_directory(migrations_path)
19+
fuzzy_path = Path.join(migrations_path, "*_#{base_name}")
20+
21+
if Path.wildcard(fuzzy_path) != [] do
22+
Mix.raise("Migration can't be created, there is already a migration file with name #{name}.")
23+
end
24+
25+
assigns = [mod: Module.concat([Mongo, Migrations, camelize(name)])]
26+
create_file(file, migration_template(assigns))
27+
String.to_integer(current_timestamp)
28+
end
29+
30+
defp timestamp do
31+
{{y, m, d}, {hh, mm, ss}} = :calendar.universal_time()
32+
"#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}"
33+
end
34+
35+
defp pad(i) when i < 10, do: <<?0, ?0 + i>>
36+
defp pad(i), do: to_string(i)
37+
38+
def migration_file_path() do
39+
path = Mongo.Migration.get_config()[:path]
40+
Path.join(["priv", path])
41+
end
42+
43+
embed_template(:migration, """
44+
defmodule <%= inspect @mod %> do
45+
def up() do
46+
# The `up` functions will be executed when running `mix mongo.migrate`
47+
#
48+
# indexes = [[key: [files_id: 1, n: 1], name: "files_n_index", unique: true]]
49+
# Mongo.create_indexes(<%= inspect(Mongo.Migration.get_config()[:topology]) %>, "my_collection", indexes)
50+
end
51+
52+
def down() do
53+
# The `down` functions will be executed when running `mix mongo.drop`
54+
#
55+
# Mongo.drop_collection(<%= inspect(Mongo.Migration.get_config()[:topology]) %>, "my_collection")
56+
end
57+
end
58+
""")
59+
end

0 commit comments

Comments
 (0)