원클릭으로
bedrud-jobs
Async job queue, scheduler, room cleanup, recording service/store, chat upload storage.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Async job queue, scheduler, room cleanup, recording service/store, chat upload storage.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Admin endpoints — users, rooms, queue, settings, invite tokens, webhooks, overview, recordings.
Auth endpoints — JWT flow, local/OAuth/passkey/verify/password-reset/avatar/preferences.
Room endpoints — CRUD, join, moderation, presence, chat upload, stage stubs, recording.
Complete Bedrud API endpoint reference. Dense index of every route, middleware, status code; deep detail in leaf skills.
All DTO definitions, source file index, Swagger reference.
Auth service + middleware — JWT, passkeys, OAuth, session store, rate limiting, recordings gate.
| name | bedrud-jobs |
| description | Async job queue, scheduler, room cleanup, recording service/store, chat upload storage. |
| license | Apache License |
Go module bedrud. Packages:
| Package | Path |
|---|---|
| Queue | server/internal/queue/ |
| Scheduler | server/internal/scheduler/ |
| Services | server/internal/services/ |
| Storage | server/internal/storage/ |
Worker polls jobs table. Postgres: FOR UPDATE SKIP LOCKED. SQLite: two-step claim (safe with SetMaxOpenConns(1)).
internal/queue/ — Job Queue| File | Purpose |
|---|---|
job.go | 8 payload structs |
queue.go | Enqueue, Handler, Worker, opts, depth cap |
worker.go | Claim, dispatch, retry, stale recovery, cleanup helpers |
handler_user_delete.go | Hard-delete user + rooms |
handler_room_delete.go | Archive (Purge=false) or cascade hard-delete (Purge=true) |
handler_room_suspend.go | Suspend room |
handler_chat_upload.go | Async S3 chat image upload |
handler_email.go | SMTP transactional email + branding |
handler_dispatch_webhook.go | HMAC-signed outbound webhooks |
handler_process_recording.go | Download LK egress → store → complete + webhook |
handler_recording_delete.go | Hard-delete recording DB row |
handler_stubs.go | Index comment only — no stub handlers remain |
templates/* | HTML + plaintext email templates |
type Handler func(ctx context.Context, db *gorm.DB, job *models.Job) error
Enqueue(ctx, db, jobType, payload, opts...) error
// opts: WithPriority(p), WithMaxAttempts(n), WithRunAt(t)
// Default priority 0, maxAttempts 3, runAt=now
// Refuses enqueue when pending+active >= maxQueueDepth (10000)
GetMaxDepth() int64
WorkerOptions{Interval: 500ms, Concurrency: 1} // from QueueConfig
NewWorker(db, handlers map[string]Handler, opts) *Worker
Start(ctx) // recoverStaleJobs then spawn Concurrency goroutines
Stop() // close stopCh
Claim loop: each tick drains all available jobs (inner loop until claim returns nil).
| Behavior | Detail |
|---|---|
| Job timeout | 5 min per handler (context.WithTimeout) |
| Panic | recovered → markError |
| No handler | markPermanentFail (no retries) |
| Success | status done |
| Failure | exponential backoff or failed |
| Stale recovery | on Start: active + updated_at >10m → pending |
markError: if attempts >= maxAttempts → failed. Else:
run_at = now + min(2^attempts * 5s, 1h)
status = pending
Default maxAttempts=3 → backoffs after attempts 1,2: ~10s, ~20s (capped 1h).
| Fn | Purpose |
|---|---|
CleanupJobs(db, cutoff) | Delete done older than cutoff |
CleanupFailedJobs(db, cutoff) | Delete failed older than cutoff |
Scheduler: daily 03:00 — done >7d, failed >30d.
models.Job)Statuses: pending → active → done | failed.
Fields: ID, Type, Payload (JSON text), RunAt, Priority (lower=higher), Status, Attempts, MaxAttempts, LastError, timestamps.
| Type | Struct | Typical priority | Handler | Behavior |
|---|---|---|---|---|
user_delete | UserDeletePayload{UserID, Email, RoomIDs} | 1 | NewUserDeleteHandler | Fetch rooms → DeleteUserRooms → passkeys → prefs → user |
room_delete | RoomDeletePayload{RoomID, SystemEvent, SystemMessage, DeletedIdentity, Purge} | 1 | NewRoomDeleteHandler | Purge=true → CascadeDeleteRoom; false → ArchiveRoom |
room_suspend | RoomSuspendPayload{RoomID} | 2 | NewRoomSuspendHandler | SuspendRoom |
chat_upload_s3 | ChatUploadS3Payload{Data(base64), RoomID, MimeType, UserID} | 0 | NewChatUploadS3Handler | Decode → Store → tracker.Record(..., "s3") |
send_email | SendEmailPayload{To, Subject, TemplateName, TemplateData} | 0 | NewSendEmailHandler | Render template + branding → SMTP (or log-skip if no SMTP) |
dispatch_webhook | WebhookPayload{URL, Event, Body, Secret} | 0 | NewDispatchWebhookHandler | HMAC POST, soft-fail (always nil) |
process_recording | ProcessRecordingPayload{RoomID, RoomName, EgressID, FileURL, FileSize, RecordingType, DurationMs, StartedAt} | 0 | NewProcessRecordingHandler | Download → store → complete + enqueue webhooks |
recording_delete | RecordingDeletePayload{RecordingID, RoomID, RoomName} | 1 | NewRecordingDeleteHandler | Hard-delete recording DB row |
Enqueue sources:
| Type | Who enqueues |
|---|---|
user_delete | handlers/users.go (delete / bulk) |
room_delete | handlers/room.go — user end meeting Purge=false; admin close / bulk Purge=true |
room_suspend | admin suspend / bulk |
chat_upload_s3 | UploadChatImage when S3 backend + large file |
send_email | auth (welcome, verify, reset, password_changed), admin |
dispatch_webhook | room lifecycle + process_recording for recording.completed |
process_recording | LiveKitWebhookHandler.handleEgressEnded |
recording_delete | RecordingHandler bulk delete |
process_recording (SHIPPED)NewProcessRecordingHandler(recordingRepo, webhookRepo, lkHost, lkInternalHost, lkAPIKey, lkAPISecret, recStore)
processing (else skip)FileURL (5m timeout)resolveDownloadURL appends short-lived JWT (RoomAdmin + room, 5m TTL). Cloud/pre-signed URLs used as-isrecordings/{createdBy}/{roomID}/{recordingID}-{started}-{completed}.mp4recStore.Store → recordingRepo.UpdateCompleteddispatch_webhook for active recording.completed subscribersrecording_delete (SHIPPED)NewRecordingDeleteHandler(recordingRepo) — fetch by ID, DeleteRecording. Missing row is success. Does not call RecordingStore.Delete (file cleanup is retention/cascade paths).
send_email (SHIPPED)Templates (HTML+txt): welcome, room_invite, password_reset, password_changed, verify_email, generic. Branding from EmailConfig.Templates overlaid by SystemSettings. SMTPS/STARTTLS via utils.SendSMTP. 30s send timeout. No SMTP → log body, return nil.
dispatch_webhook (SHIPPED)Envelope: {event, timestamp, data}. Headers: X-Bedrud-Signature (sha256= HMAC-SHA256), X-Bedrud-Event, X-Bedrud-Timestamp. 10s HTTP timeout. Soft-fail all errors (no retry).
type QueueConfig struct {
PollInterval ConfigInt // ms, default 500. Env: QUEUE_POLL_INTERVAL
MaxAttempts int // default 3. Env: QUEUE_MAX_ATTEMPTS
Concurrency int // default 1. Env: QUEUE_CONCURRENCY
}
server/internal/server/server.go)Registered today:
"user_delete", "room_delete", "room_suspend",
"chat_upload_s3", "send_email", "dispatch_webhook"
process_recording / recording_delete handlers and NewRecordingStore / egress / RecordingService are implemented but currently commented out in bootstrap with leftover TODOs — jobs can be enqueued by webhook/admin paths while worker map omits them until wiring is restored. Stale active jobs with no handler → permanent fail.
internal/scheduler/scheduler.go — gocron TasksInitialize(db, roomRepo, userRepo, recordingRepo, lkCfg, serverCfg, recStore, recCfg)
Stop()
| Interval | Task |
|---|---|
| Every 1 min | CleanupExpiredRooms — bulk mark expired inactive (excludes persistent) |
| Every 1 min | checkIdleRooms — LK participant counts → idle if 0 + >5m old. Skip persistent. Reactivate if join during check. Deactivate participants |
| Weekly 03:00 | DeleteGuestUsers — guests older than 7d |
| Daily 03:30 | DeleteUnverifiedAccounts — unverified local/passkey (TTL default 48h, auth.unverifiedAccountTTLHours) |
| Hourly | CleanupBlockedTokens |
| Hourly | auth.PruneRevokedTokens |
| Daily 03:00 | Queue: done >7d / failed >30d |
| Daily 03:00 | Stale recordings — recordingRepo.DeleteStaleRecordings(now-7d) when recordingRepo != nil |
| Daily 09:00 | TLS cert expiry — renew self-signed if ≤CertWarnDays (30) or expired (only when non-ACME TLS) |
Every CleanupIntervalHours (default 24) | Recording retention — if recCfg.RetentionHours > 0 && recStore != nil |
FindExpiredOnArchivedRooms(cutoff) where cutoff = now − RetentionHoursExtractStorageKey → recStore.Delete → DeleteRecordingFindArchivedRoomsNoRecordings → HardDeleteRoom for empty archived roomsIf retentionHours=0 or no store: retention disabled (logged).
Note: bootstrap currently passes recStore=nil, recCfg=nil so retention is off until store is wired.
internal/services/recording_service.go — RecordingService (SHIPPED)LiveKit Egress orchestration. Gates: system → room exists → room allow → max per room (non-persistent) → no active recording.
NewRecordingService(settingsRepo, recordingRepo, roomRepo, egressClient, apiKey, apiSecret)
| Fn | Purpose |
|---|---|
StartRecording(ctx, roomID, createdBy) | Create pending row → StartRoomCompositeEgress (MP4, AudioOnly=true) → optimistic lock to started with egress ID. On egress fail: delete row. On DB update fail after start: best-effort StopEgress |
StopRecording(ctx, roomID) | StopEgress only — does not set processing/completed. Webhook egress_ended owns lifecycle. On stop fail: mark failed |
ListRecordings(roomID, page, limit) | Paginated room list |
ListAdminRecordings(...) | Admin filters: roomID, status, date range |
GetRecording(id) | Single row |
Errors: ErrRecordingsDisabled, ErrRecordingsNotAllowed, ErrActiveRecordingExists, ErrRoomNotFound, ErrNoActiveRecording, ErrEgressClientNotReady, ErrMaxRecordingsPerRoom.
Lifecycle:
pending → (egress start) → started
→ (egress_ended webhook) → processing → enqueue process_recording
→ (handler) → completed | failed
→ (bulk delete) → deleting → recording_delete job
Egress auth: lkutil.AuthContext with RoomRecord + RoomJoin + room-scoped grant.
internal/services/room_cleanup.go — RoomCleanupServiceNewRoomCleanupService(roomRepo, recordingRepo, lkClient, egressClient, apiKey, apiSecret, uploadTracker)
| Fn | Purpose |
|---|---|
CascadeDeleteRoom(ctx, room, CascadeDeleteOptions) | Stop active egress → system msg → LK DeleteRoom → chat upload cleanup → recordingRepo.DeleteByRoom → HardDeleteRoom |
ArchiveRoom(ctx, room) | Stop egress → system msg → LK DeleteRoom → chat uploads → deactivate participants → SoftDeleteRoom (recordings preserved) |
SuspendRoom(ctx, room) | Stop egress → system msg → LK DeleteRoom → chat uploads → delete recording rows → SetRoomIdle + deactivate participants |
DeleteUserRooms(ctx, rooms, deletedUserID) | Cascade each with owner-removed message |
BulkSuspendRooms / BulkCloseRooms | Batch helpers; per-room error map |
CascadeDeleteOptions{SystemEvent, SystemMessage, DeletedIdentity}
cleanupRecordings stops active egress via egress client (best-effort). Recording file deletion on cascade relies on DB row delete + separate retention; individual file wipe for cascade is not full S3/disk walk unless retention/store paths run.
internal/storage/recording_store.go — RecordingStore (SHIPPED)type RecordingAttachment struct {
URL string `json:"url"`
Size int64 `json:"size"`
}
type RecordingStore interface {
Store(ctx, key string, src io.Reader, size int64) (*RecordingAttachment, error)
Delete(ctx, key string) error
}
NewRecordingStore(cfg *RecordingConfig, s3Cfg *ChatUploadS3Config) RecordingStore
ExtractStorageKey(fileURL string) string // disk or S3 URL → storage key
| Backend | When | URL shape |
|---|---|---|
| Disk | default / incomplete S3 | /recordings/{key} under storageDir (default ./data/recordings) |
| S3 | endpoint+bucket+accessKey set | {publicBaseURL}/{key} or {endpoint}/{bucket}/{key} |
Disk: path-traversal guard, temp+rename atomic write, optional max size, prune empty parent dirs on delete. S3: raw AWS SigV4 (shared helpers with chat upload); content-type video/mp4 or video/webm.
type RecordingConfig struct {
MaxFileSizeMB int // 0=unlimited; yaml default 2048
StorageDir string // default ./data/recordings
MaxRecordingsPerRoom int // 0=unlimited; non-persistent rooms only
RetentionHours int // 0=forever; default 720 (30d). Env: RECORDING_RETENTION_HOURS
CleanupIntervalHours int // 0→24 when retention on. Env: RECORDING_CLEANUP_INTERVAL_HOURS
}
internal/storage/chat_upload.go — Chat Upload Storagetype ChatAttachment struct {
Kind, URL, Mime string
Size int64
Width, Height int // json: w, h
}
ChatUploadStore interface { Store(data []byte) (*ChatAttachment, error) }
ObjectDeleter interface { DeleteObject(key string) error }
NewChatUploadStore(cfg *ChatUploadConfig) ChatUploadStore
NewS3Deleter(cfg *ChatUploadS3Config) ObjectDeleter
SniffMime / ContentHash / imageDimensions (incl. WebP)
| Backend | Rule |
|---|---|
disk / default | hybridStore: size < InlineMaxBytes (default 500KB) → data URI; else disk |
inline | always base64 data URI |
s3 | small → inline; else SigV4 PUT; incomplete S3 → disk fallback |
MIME allowlist: png/jpeg/gif/webp. Filename = SHA256 + ext. Disk paths: /uploads/chat/{hash}{ext}.
NewChatUploadTracker(db, chatDir, deleter)
Record(roomID, userID, fileHash, ext, fileSize, backend) error
DeleteByRoom(roomID) error // cross-room refcount on file_hash; S3/disk/inline
GetUserUploadBytes(userID) (int64, error)
GetTotalUploadBytes() (int64, error) // 60s cache
internal/storage/avatar.go — User avatarsNot queue-driven. Disk under ./data/uploads/avatars. Max 2MB, max dim 1024. Same MIME allowlist. SaveUserAvatar / DeleteUserAvatarFiles / ResolveAvatarFile.
RecordingService.StartRecording
→ models.Recording pending/started + LK RoomCompositeEgress (audio-only MP4)
LiveKit webhook egress_ended
→ status processing + Enqueue(process_recording)
process_recording handler
→ download (JWT if embedded LK) → RecordingStore.Store → completed
→ optional dispatch_webhook(recording.completed)
User ends meeting → room_delete Purge=false → ArchiveRoom (recordings kept)
Admin close → room_delete Purge=true → CascadeDeleteRoom
Scheduler retention → delete expired completed on archived rooms + empty rooms
Scheduler stale → DeleteStaleRecordings (7d pending/started/failed)
Bulk admin delete → MarkDeleting + Enqueue(recording_delete)