Files
upstream-ent/doc/md/blob-storage.md
upstream-mirror 4b3ad0b611
Some checks failed
Atlas CI Public / lint (push) Failing after 13s
Atlas CI Public / sync (push) Has been skipped
snapshot of https://github.com/ent/ent.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 22:12:36 +05:00

387 lines
14 KiB
Markdown

---
id: blob-storage
title: Blob Storage
---
## Overview
Ent's `field.Blob` type stores field data in external blob storage (e.g., S3, GCS, local filesystem)
rather than in the database. The database only stores a **key** (a short string) referencing the blob.
This keeps database rows small while supporting arbitrarily large binary or text payloads.
## Quick Start
### 1. Define a schema with blob fields
```go
package schema
import (
"crypto"
"entgo.io/ent"
"entgo.io/ent/schema/field"
)
type Document struct {
ent.Schema
}
func (Document) Fields() []ent.Field {
return []ent.Field{
field.String("name").Unique(),
field.Blob("content"),
field.Blob("avatar").Optional(),
}
}
```
### 2. Configure blob storage
Each entity type with blob fields requires a `BlobOpener` — a function that opens a
blob bucket for a given field name:
```go
import (
"context"
"myapp/ent"
"myapp/ent/document"
_ "gocloud.dev/blob/s3blob" // or fileblob, gcsblob, etc.
)
client := ent.NewClient(
ent.Driver(drv),
ent.WithBlobOpeners(ent.BlobOpeners{
Document: func(ctx context.Context, field string) (ent.Blob, error) {
switch field {
case document.FieldContent:
return blob.OpenBucket(ctx, "s3://my-bucket/content")
case document.FieldAvatar:
return blob.OpenBucket(ctx, "s3://my-bucket/avatars")
default:
return nil, fmt.Errorf("unknown blob field: %s", field)
}
},
}),
)
```
### 3. Use the generated API
```go
// Create — pass []byte directly.
doc := client.Document.Create().
SetName("readme").
SetContent([]byte("Hello, World!")).
SaveX(ctx)
// Read — the struct field holds the loaded value.
fmt.Println(string(doc.Content)) // "Hello, World!"
// Update
doc = doc.Update().
SetContent([]byte("Updated content")).
SaveX(ctx)
```
## `field.Blob` API
| Method | Description |
|--------|-------------|
| `Optional()` | Field is nullable; not required on create. |
| `Nillable()` | Struct field is a pointer (distinguishes zero value from unset). |
| `Immutable()` | Field can only be set on create, not updated. |
| `Lazy()` | Mutation accepts `io.Reader`; struct field omitted; use Reader method to read. |
| `HashKey(h)` | Content-addressable key via hash (default: `crypto.SHA256`). |
| `UUIDKey()` | Random UUID v7 key per write. |
| `CheckRefs()` | Verify no row still holds a key before deleting the object. Use with `HashKey`. |
| `DualWrite(...)` | Migration mode: write to both blob storage and database column. |
| `GoType(typ)` | Override the default `[]byte` Go type. |
| `ValueScanner(vs)` | Custom codec between Go type and raw bytes (required for non-`[]byte`/`string` GoType). |
| `StorageKey(key)` | Override the database column name. |
| `StructTag(s)` | Set the struct tag on the generated field. |
| `Comment(c)` | Set the field comment. |
| `Annotations(...)` | Attach codegen annotations. |
| `Deprecated(...)` | Mark the field as deprecated. |
## Key Strategies
Every blob field requires a **key function** that determines the storage key for a given
piece of data. By default (when neither `UUIDKey` nor `HashKey` is called), blobs use
SHA-256 content hashing.
### HashKey (default)
Content-addressable storage: the data is hashed to produce the key. Identical content
always maps to the same key, enabling deduplication and write-skip optimizations on update.
```go
field.Blob("content").HashKey(crypto.SHA256)
```
On update, if the new content produces the same hash as the existing key, the write to
blob storage is skipped entirely.
Because rows with identical content share one object, pair `HashKey` with
[`CheckRefs`](#shared-objects-and-checkrefs) so cleanup does not remove an object another row
still points at.
### UUIDKey
Each write generates a new random UUID (v7) as the storage key. This guarantees uniqueness
but does not deduplicate.
```go
field.Blob("content").UUIDKey()
```
No two rows can hold the same key, so cleanup can remove an object as soon as its row lets go.
Leave [`CheckRefs`](#shared-objects-and-checkrefs) off here — the lookup would run on every
mutation to answer a question whose answer is fixed.
## Lazy Fields
By default, blob data is automatically loaded into the entity struct field when the row is
scanned from the database. For large blobs where you don't always need the data in memory,
use `Lazy()`:
```go
field.Blob("content").Lazy()
```
With `Lazy()`:
- The **mutation builder** accepts an `io.Reader` (which is fully buffered before writing).
- The entity **struct field is omitted** — data is not loaded on scan.
- A **Reader method** (e.g., `ContentReader`) is generated to explicitly open a reader from storage.
```go
// Create with io.Reader.
doc := client.Document.Create().
SetName("large-file").
SetContent(bytes.NewReader(largeData)).
SaveX(ctx)
// Read explicitly via the Reader method.
rc, err := doc.ContentReader(ctx)
if err != nil { ... }
defer rc.Close()
data, _ := io.ReadAll(rc)
```
## DualWrite (Migration Mode)
`DualWrite()` preserves the original bytes column alongside the blob key column. This is
useful when migrating an existing column to blob storage:
```go
field.Blob("payload").
DualWrite(map[string]string{
dialect.MySQL: "json",
dialect.Postgres: "jsonb",
dialect.SQLite: "json",
})
```
In DualWrite mode:
- **Writes** go to both blob storage and the database column.
- **Reads** prefer blob storage (if a key exists) and fall back to the database column.
The optional `columnType` argument overrides the database column type per dialect to prevent
schema drift when migrating from an existing column definition.
## Custom GoType
Blob fields default to `[]byte`. You can override the Go type:
```go
// String type (automatic conversion).
field.Blob("description").GoType("")
// Custom struct with a ValueScanner.
field.Blob("config").
GoType(&MyConfig{}).
ValueScanner(configScanner{})
```
When using a custom `GoType` other than `string`, a `ValueScanner` must be provided
to encode/decode between the Go type and the raw bytes stored in blob storage.
## Blob Interface
Any blob storage backend must implement the `ent.Blob` interface:
```go
type Blob interface {
NewReader(ctx context.Context, key string) (io.ReadCloser, error)
NewWriter(ctx context.Context, key string) (io.WriteCloser, error)
Delete(ctx context.Context, key string) error
Close() error
}
```
- `NewReader` should return `fs.ErrNotExist` (or a wrapping error) when the key does not exist.
- `Delete` should return `nil` (not an error) when the key does not exist.
The [Go CDK `blob` package](https://gocloud.dev/howto/blob/) provides implementations
for S3, GCS, Azure, and local filesystem that satisfy this interface via a thin adapter.
## Lifecycle and Cleanup
### Shared objects and `CheckRefs`
By default, cleanup removes a blob object as soon as the row that wrote it goes away or moves
off the key. That is correct exactly when no two rows can hold the same key — the guarantee
[`UUIDKey`](#uuidkey) gives you, since every write generates a fresh key.
It is **not** correct for `HashKey`. There the key *is* the content, so any two rows holding
identical content point at one object in storage. That is what makes deduplication work, and
it means removing the object because one row let go can strand every other row still pointing
at it. Concretely, without the check:
- A failed INSERT rolls back "its" blob — which may be the object an existing row with the
same content already points at.
- Deleting one of two rows with identical content removes the object the other still uses.
- Updating a row off a shared key removes an object another row still holds.
`CheckRefs` turns on the guard. Add it to every content-addressed blob field:
```go
field.Blob("content").
HashKey(crypto.SHA256).
CheckRefs()
```
It is opt-in and per field, so a schema can mix strategies: pay for the lookup on the
content-addressed fields and skip it on the UUID-keyed ones, where the answer is always the
same. With it on, every cleanup path looks up the keys it is about to remove in their key
columns and skips the ones a row still holds.
The lookup is timed so the table already reads the way the mutation will leave it — before
the INSERT for blobs written ahead of a row, after the UPDATE or DELETE for keys the mutation
gives up — and always while the statement and its transaction are still open. A row inserted
concurrently between the lookup and the delete is not seen. Closing that window entirely
requires reference counting or a periodic sweep of the bucket against the key columns.
### Index the key columns
**Index the key column of every field using `CheckRefs`.** The lookup asks only *whether* a
key is still in use, never how often, so an index lets it stop at the first matching row
instead of scanning the table. Every create, update, and delete touching that field runs the
query, so an unindexed key column turns each of those mutations into a full table scan.
Declare the index by **field** name — for a blob field, `index.Fields` targets the generated
key column (`<field>_key` by default):
```go
func (Document) Indexes() []ent.Index {
return []ent.Index{
index.Fields("content"), // indexes content_key
index.Fields("avatar"), // indexes avatar_key
}
}
```
Leave them non-unique — rows sharing content share a key, which is the whole point.
One exception: a `DualWrite` field that is not also `Lazy()` keeps a real data column, and
`index.Fields` targets *that* column rather than the key column. For those fields, create the
index on `<field>_key` outside the schema.
Fields without `CheckRefs` need no such index — they never run the lookup.
### Create
Blobs are written **before** the database row is inserted. If the SQL INSERT fails
(e.g., constraint violation), the generated code deletes the just-written blobs — except,
for `CheckRefs` fields, any that rows in the database already reference.
### Update
On update:
1. New blob data is written to storage.
2. The SQL UPDATE executes.
3. On success, old blobs are deleted — for `CheckRefs` fields, unless another row still
references them.
4. On SQL failure, newly-written blobs are rolled back (deleted) under the same rule.
When using `HashKey`, if the content hasn't changed (same hash), the write is skipped entirely.
### Delete
Generated delete builders query existing blob keys before deleting the row, then remove
the blobs from storage after a successful SQL DELETE — keeping, for `CheckRefs` fields, any
object that rows outside the deleted set still reference.
## OnConflict (Upsert)
Blob fields work with `OnConflict` / upsert builders. Blobs are written to storage
**before** the SQL executes. If the INSERT succeeds (no conflict), the new key is stored.
If there is a conflict, behavior depends on the conflict action and key strategy.
The following per-field methods are generated on the upsert builder:
- **`Update<Field>()`** — Sets the blob key column (and data column for DualWrite fields) to
the value provided on create.
- **`Clear<Field>()`** — Nulls the blob key column (and data column for DualWrite). Only
generated for optional fields. **Note:** this does not delete the old blob from storage —
the previously-referenced blob becomes orphaned. Use a regular Update with `Clear<Field>()`
if you need storage cleanup.
```go
client.Document.Create().
SetName("readme").
SetContent([]byte("data")).
SetAttachment([]byte("file")).
OnConflict().
UpdateContent(). // update only the content blob
UpdateAttachment(). // update only the attachment blob
ExecX(ctx)
```
You can also use `UpdateNewValues()` to update all fields (including all blob key columns)
at once.
### With HashKey (content-addressable)
Since the key is derived from content, identical data always produces the same key:
- **`Update<Field>()`** — The SQL updates the key column to the new key. If the content
matches what the row already held, the key is unchanged and it is a no-op in storage.
If the content differs, the row moves to the new key and the old object is left behind —
`OnConflict` does not query old keys the way a regular Update does.
- **`Ignore()`** — The SQL sets each column to itself. If the content differs from the
existing row's, the blob written to storage has no database reference.
- **`DoNothing()`** — No row is inserted, which surfaces as `sql.ErrNoRows`. The blob
written to storage is deleted, unless the conflicting row already references that same
key — the common case when the content is identical.
Upserting identical content is idempotent in storage: the write lands on the key that is
already there, and cleanup leaves it in place because the existing row references it.
```go
client.Document.Create().
SetName("readme").
SetContent([]byte("data")).
OnConflict().
UpdateContent().
ExecX(ctx)
```
### With UUIDKey (random keys)
Each write generates a new unique key. This means the blob written before the SQL is always
at a **new** key that didn't previously exist:
- **`Update<Field>()`** — The SQL updates the key column to the new UUID key. The old blob
at the previous key becomes orphaned and is **not** automatically cleaned up (OnConflict
does not query old keys like a regular Update does).
- **`Ignore()`** — The SQL succeeds without changing anything; the row keeps its existing
key. The newly-written blob is orphaned in storage with no database reference.
- **`DoNothing()`** — No row is inserted, which surfaces as `sql.ErrNoRows`. Because a fresh
UUID is referenced by nobody, the newly-written blob is deleted.
For these reasons, `UUIDKey` is **not recommended** with `OnConflict`. If you need upsert
semantics, prefer `HashKey` which is inherently idempotent.