
Els desenvolupadors de FileMaker que construeixen integracions es fan sempre la mateixa pregunta: puc bloquejar un registre a través de la Data API o OData i fer que aquest bloqueig sigui visible per a tothom que fa servir la base de dades? La resposta curta és no. Els bloquejos natius pertanyen als clients interactius, i les APIs són sense estat. Però la necessitat és real, especialment a mesura que interfícies més riques es traslladen als WebViewers i la UI s’ha de coordinar amb la base de dades mitjançant guions.
Aquest article descriu un bloqueig tou basat en concessions (leases) que funciona a FileMaker Pro, Go, WebDirect, WebViewers i clients API. Reflecteix la meva comprensió actual, i les correccions i enfocaments alternatius són benvinguts.
1. Per què els bloquejos natius no es traslladen #
El bloqueig natiu de registres pertany a un client que té un registre en mode d’edició, i dura només el que dura l’edició. La Data API i OData són sense estat: cada petició és independent, un guió executat a través d’una API acaba quan retorna la resposta, i cap de les dues APIs té un endpoint de bloqueig. FileMaker Server tampoc té manera d’enviar un bloqueig als clients connectats.
Per tant, un guió del costat del servidor no pot mantenir un bloqueig natiu entre peticions, ni pot posar un camp en mode d’edició en nom d’un usuari d’API. El que un guió sí pot fer és mantenir un bloqueig natiu durant la seva pròpia execució. Això és inútil com a bloqueig en si, però és exactament el que necessitem com a mutex curt que fa atòmiques les transicions de bloqueig (secció 4).
2. Principis de disseny #
- Una taula separada. L’estat de bloqueig viu a la seva pròpia taula
Locks, mai com a camps del registre de dades. Els heartbeats escrits al registre de dades incrementarien el seumodId(trencant la concurrència optimista), xocarien amb els editors (error 301) i contaminarien les marques de modificació, els registres d’auditoria i els triggers. - Concessions, no bloquejos. Un bloqueig és una concessió amb expiració que el titular renova contínuament. Un client que cau simplement deixa de renovar.
- Un sol rellotge. Tota la temporització fa servir els mil·lisegons UTC del servidor. Els rellotges dels clients mai no es comparen.
- Un token de fencing. L’adquisició retorna un token secret. Renovar, alliberar i cada escriptura l’han de presentar, de manera que un client que s’ha adormit més enllà de la seva concessió no pugui actuar sobre un bloqueig que ja no li pertany.
- Transicions atòmiques. Cada canvi d’estat passa mentre la fila de bloqueig està oberta (bloquejada nativament) dins d’un mateix guió, cosa que tanca la cursa que el disseny anterior només estretia.
- Imposició per privilegis. Les escriptures directes a les taules protegides es deneguen. Les escriptures passen per guions que verifiquen el bloqueig.
- Dues capes. El bloqueig tou evita l’edició concurrent.
modIdatrapa el que encara s’hi coli.
Una conseqüència convé enunciar-la aviat: una concessió que ha expirat però no ha estat presa per un altre segueix sent vàlida, perquè el token demostra que ningú més va adquirir el bloqueig mentrestant. L’expiració només decideix quan altres poden prendre el control.
3. La taula Locks #
Una fila per registre bloquejable, creada mandrosament al primer ús.
| Camp | Tipus | Notes |
|---|---|---|
id | Text | Clau primària, auto-introduir Get ( UUID ) |
lock_key | Text | target_table & "|" & target_uuid — clau única per registre |
target_table | Text | Nom lògic de la taula bloquejada |
target_uuid | Text | UUID estable del registre bloquejat (mai l’ID intern del registre) |
owner_id | Text | Compte o identitat de l’usuari. Buit vol dir lliure |
owner_name | Text | Nom a mostrar als avisos |
owner_client | Text | pro, go, webviewer, api |
lock_token | Text | Get ( UUID ) per adquisició. Un secret, mai retornat per Status |
generation | Nombre | +1 a cada adquisició. Monòton, útil en logs i per a fencing posterior |
acquired_ms, renewed_ms, expires_ms | Nombre | Valors de Get ( CurrentTimeUTCMilliseconds ). L’UTC numèric evita l’ambigüitat de l’horari d’estiu |
renew_count | Nombre | Heartbeats a la concessió actual |
El lock_key únic converteix una cursa de creació en l’error 504 (valor no únic), que els guions tracten com “un altre l’ha creat primer, cerca’l de nou”.
Afegeix una petita taula LockLog (ts_ms, lock_key, event, owner_id, generation, detail). Esdeveniments: ACQUIRE, REACQUIRE, TAKEOVER, DENIED, RELEASE, EXPIRE, FORCE_RELEASE, WRITE_OK, WRITE_REJECTED. Costa gairebé res i estalvia hores quan algú pregunta per què un registre estava bloquejat.
4. Els guions #
Tots els guions prenen un paràmetre JSON, retornen JSON mitjançant Exit Script, i tenen activat Executar guió amb privilegis d’accés total. Una funció personalitzada LockResult ( ok ; code ; message ; data ) construeix el resultat:
{ "ok": 1, "code": "GRANTED", "message": "", "data": { ... } }
| Codi | Significat |
|---|---|
| GRANTED, RENEWED, RELEASED, STATUS | Èxit |
| DENIED | El té una altra persona. data conté owner, since, expires |
| LOST | El token ja no és vàlid (alliberat o pres per un altre) |
| BUSY | Contenció a la fila de bloqueig. Reintentar aviat |
| IN_USE | El registre de dades està bloquejat nativament per un editor (error 301) |
| CONFLICT | modId o els valors esperats ja no coincideixen |
| INVALID, NOT_FOUND, ERROR | Entrada incorrecta, registre inexistent o un error inesperat de FileMaker (a data) |
4.1 Lock_Open_Row (subguió) #
Cerca la fila de bloqueig, la crea si es permet, i la deixa oberta a la sessió actual. Tot la resta es construeix sobre això.
# Lock_Open_Row param: { key, table, id, create }
Set Error Capture [ On ]
Set Variable [ $p ; Get ( ScriptParameter ) ]
Set Variable [ $key ; JSONGetElement ( $p ; "key" ) ]
Set Variable [ $create ; GetAsNumber ( JSONGetElement ( $p ; "create" ) ) ]
Go to Layout [ "Locks" ]
Set Variable [ $try ; 0 ]
Loop
Set Variable [ $try ; $try + 1 ]
Exit Loop If [ $try > 6 ]
Enter Find Mode [ Pause: Off ]
Set Field [ Locks::lock_key ; "==" & $key ]
Perform Find [ ]
If [ Get ( LastError ) = 401 ] # no row yet
If [ not $create ]
Exit Script [ "MISSING" ]
End If
New Record/Request
Set Field [ Locks::lock_key ; $key ]
Set Field [ Locks::target_table ; JSONGetElement ( $p ; "table" ) ]
Set Field [ Locks::target_uuid ; JSONGetElement ( $p ; "id" ) ]
Commit Records/Requests [ No dialog ]
If [ Get ( LastError ) ≠ 0 ] # 504: another script created it first
Revert Record/Request [ No dialog ]
End If
Else
Open Record/Request
If [ Get ( LastError ) = 0 ]
Exit Script [ "OK" ]
End If
# 301: another script holds the row right now (milliseconds)
Pause/Resume Script [ Duration (seconds): 0.15 ]
End If
End Loop
Exit Script [ "BUSY" ]
4.2 Lock_Acquire #
# Lock_Acquire param: { table, id, user, name, client, ttl }
Set Error Capture [ On ]
Set Variable [ $in ; Get ( ScriptParameter ) ]
Set Variable [ $table ; JSONGetElement ( $in ; "table" ) ]
Set Variable [ $id ; JSONGetElement ( $in ; "id" ) ]
Set Variable [ $user ; Get ( AccountName ) ] # see section 5 for shared service accounts
Set Variable [ $ttl ; Case ( IsEmpty ( JSONGetElement ( $in ; "ttl" ) ) ; 90 ;
Min ( Max ( GetAsNumber ( JSONGetElement ( $in ; "ttl" ) ) ; 30 ) ; 600 ) ) ]
If [ IsEmpty ( $table ) or IsEmpty ( $id ) ]
Exit Script [ LockResult ( 0 ; "INVALID" ; "table and id are required" ; "" ) ]
End If
Set Variable [ $key ; $table & "|" & $id ]
Perform Script [ "Lock_Open_Row" ; Parameter: JSON ( key, table, id, create = 1 ) ]
If [ Get ( ScriptResult ) ≠ "OK" ]
Exit Script [ LockResult ( 0 ; "BUSY" ; "Lock table busy, retry" ; "" ) ]
End If
# The row is open, so these values are current and nobody else can change them
Set Variable [ $now ; Get ( CurrentTimeUTCMilliseconds ) ]
Set Variable [ $held ; not IsEmpty ( Locks::owner_id ) and Locks::expires_ms > $now ]
If [ $held and Locks::owner_id ≠ $user ]
Set Variable [ $out ; LockResult ( 0 ; "DENIED" ; "Locked by another user" ;
JSON ( owner = Locks::owner_name, since = Locks::acquired_ms, expires = Locks::expires_ms ) ) ]
Revert Record/Request [ No dialog ]
Perform Script [ "Lock_Log" ; "DENIED" ]
Exit Script [ $out ]
End If
Set Variable [ $event ; Case ( IsEmpty ( Locks::owner_id ) ; "ACQUIRE" ; $held ; "REACQUIRE" ; "TAKEOVER" ) ]
Set Field [ Locks::owner_id ; $user ]
Set Field [ Locks::owner_name ; JSONGetElement ( $in ; "name" ) ]
Set Field [ Locks::owner_client ; JSONGetElement ( $in ; "client" ) ]
Set Field [ Locks::lock_token ; Get ( UUID ) ] # a new token invalidates any previous holder
Set Field [ Locks::generation ; Locks::generation + 1 ]
Set Field [ Locks::acquired_ms ; $now ]
Set Field [ Locks::renewed_ms ; $now ]
Set Field [ Locks::expires_ms ; $now + $ttl * 1000 ]
Set Field [ Locks::renew_count ; 0 ]
Commit Records/Requests [ No dialog ]
If [ Get ( LastError ) ≠ 0 ]
Exit Script [ LockResult ( 0 ; "ERROR" ; "Commit failed" ; JSON ( error = Get ( LastError ) ) ) ]
End If
Perform Script [ "Lock_Log" ; $event ]
Exit Script [ LockResult ( 1 ; "GRANTED" ; "" ;
JSON ( token = Locks::lock_token, generation = Locks::generation, expires = Locks::expires_ms, ttl = $ttl ) ) ]
Una readquisició del mateix usuari des d’una altra sessió es concedeix i rota el token, de manera que la sessió anterior veurà LOST al seu proper heartbeat. Si prefereixes que sigui estricte un usuari per sessió, emmagatzema i compara també un id de sessió.
4.3 Lock_Renew #
# Lock_Renew param: { table, id, token, ttl }
Open the row (Lock_Open_Row, create = 0)
If [ result = "MISSING" or IsEmpty ( $token ) or Locks::lock_token ≠ $token ]
Revert Record/Request [ No dialog ]
Exit Script [ LockResult ( 0 ; "LOST" ; "Lock was released or taken over" ; "" ) ]
End If
# A matching token proves nobody took over, even if the lease has lapsed
Set Field [ Locks::renewed_ms ; $now ]
Set Field [ Locks::expires_ms ; $now + $ttl * 1000 ]
Set Field [ Locks::renew_count ; Locks::renew_count + 1 ]
Commit Records/Requests [ No dialog ]
Exit Script [ LockResult ( 1 ; "RENEWED" ; "" ; JSON ( expires = Locks::expires_ms ) ) ]
4.4 Lock_Release #
# Lock_Release param: { table, id, token }
Open the row (create = 0)
If [ result = "MISSING" ]
Exit Script [ LockResult ( 1 ; "RELEASED" ; "Nothing to release" ; "" ) ] # idempotent
End If
If [ Locks::lock_token ≠ $token ]
Revert Record/Request [ No dialog ] # never touch someone else's lock
Exit Script [ LockResult ( 0 ; "LOST" ; "Not the current holder" ; "" ) ]
End If
Set Field [ owner_id, owner_name, owner_client, lock_token, acquired_ms, renewed_ms, expires_ms ; "" ]
Commit Records/Requests [ No dialog ] # generation is kept
Perform Script [ "Lock_Log" ; "RELEASE" ]
Exit Script [ LockResult ( 1 ; "RELEASED" ; "" ; "" ) ]
4.5 Lock_Status #
De només lectura, fet servir per a avisos i vistes de llista. Accepta una llista de registres de manera que una pantalla de llista costa una sola crida, i mai no retorna el token.
# Lock_Status param: { items: [ { table, id }, ... ] }
Set Variable [ $now ; Get ( CurrentTimeUTCMilliseconds ) ]
For each item:
Find Locks::lock_key == table & "|" & id # no Open Record needed
If found and owner_id is not empty and expires_ms > $now
append { id, locked = 1, owner = owner_name, client = owner_client, since = acquired_ms, expires = expires_ms }
Else
append { id, locked = 0 }
Exit Script [ LockResult ( 1 ; "STATUS" ; "" ; $array ) ]
4.6 Record_Update_Guarded #
Aquesta és l’única via per la qual els clients API escriuen a una taula protegida. Verifica el token, el modId i, opcionalment, els valors originals dels camps que es modifiquen.
# Record_Update_Guarded param: { table, id, token, modId, fields: {…}, expected: {…} }
Set Variable [ $cfg ; Lock_Config ( $table ) ] # layout, uuid field, allow-list of writable fields
Open the lock row (create = 0) in this window
If [ missing or Locks::lock_token ≠ $token ]
Revert Record/Request ; Log WRITE_REJECTED
Exit Script [ LockResult ( 0 ; "LOST" ; "Lock is not held by this token" ; "" ) ]
End If
# The lock row stays open for the whole write: nobody can take over, renew or release meanwhile.
# Use a second window for the data record. Changing layout in this window would commit the lock row.
New Window [ Name: "data" ]
Go to Layout [ $cfg.layout ]
Find where $cfg.uuidField == $id # 401 -> NOT_FOUND
Open Record/Request
If [ Get ( LastError ) = 301 ]
Close Window ; Revert the lock row
Exit Script [ LockResult ( 0 ; "IN_USE" ; "Someone is editing this record" ; "" ) ]
End If
If [ not IsEmpty ( $modId ) and Get ( RecordModificationCount ) ≠ $modId ]
→ CONFLICT, with the current modId in data
End If
For each key in JSONListKeys ( $expected ):
If GetField ( key ) ≠ expected value → CONFLICT, listing the field names
For each key in JSONListKeys ( $fields ):
If key is not in $cfg.allowed → INVALID
Set Field By Name ( $table & "::" & key ; JSONGetElement ( $fields ; key ) )
Commit Records/Requests [ No dialog ]
If [ Get ( LastError ) ≠ 0 ] → Revert, ERROR with the error code (validation errors included)
Set Variable [ $newMod ; Get ( RecordModificationCount ) ]
Close Window ; Revert the lock row (nothing changed in it) ; Log WRITE_OK
Exit Script [ LockResult ( 1 ; "OK" ; "" ; JSON ( modId = $newMod ) ) ]
A qualsevol ruta de fallada, tanca la finestra i reverteix els dos registres oberts abans de sortir. La llista de permesos importa: sense ella aquest guió és un endpoint d’escriptura genèric.
4.7 Lock_Cleanup i Lock_Force_Release #
Lock_Cleanup s’executa com a guió programat del servidor cada pocs minuts. La presa de control ja gestiona l’expiració, així que la neteja existeix per mantenir nets les dades d’estat i els logs:
Find Locks where owner_id is not empty and expires_ms < Get ( CurrentTimeUTCMilliseconds ) - 10000
Loop: Open Record/Request
re-check that it is still expired (a renewal may have arrived) → clear the owner fields, Commit, Log EXPIRE
Delete LockLog rows older than 30 days
Lock_Force_Release és per a administradors: comprova Get ( PrivilegeSetName ), neteja la fila i registra FORCE_RELEASE amb el nom de l’administrador. Tota crida de suport per un bloqueig encallat acaba aquí, així que construeix-lo des del primer dia.
5. Imposició #
Un bloqueig tou és consultiu llevat que el servidor rebutgi les escriptures que se’l salten. Per als comptes d’API:
- Registres: només visualització a les taules protegides, sense edició, creació ni eliminació. Les escriptures passen per
Record_Update_Guarded. - Locks i LockLog: sense accés directe. Això també manté
lock_tokenlluny d’altres clients, ja que un token llegible és un secret de portador. - Guions: accés d’execució només als guions
Lock_*i als escriptors protegits. - Identitat: fes servir
Get ( AccountName )com a titular quan cada persona té un compte de FileMaker. Si un backend de confiança crida amb un únic compte de servei compartit, passa l’usuari al paràmetre i el bloqueig és exactament tan fiable com aquell backend.
Qualsevol cosa amb accés total, com eines d’administració i guions de servidor, encara pot saltar-se el bloqueig. Tracta el bloqueig tou com una eina de coordinació per a clients que cooperen, no com una frontera de seguretat.
6. Clients FileMaker Pro i Go #
El bloqueig natiu segueix protegint els usuaris de Pro que editen al mateix temps, però els clients API no poden veure un bloqueig natiu fins que la seva escriptura xoca amb l’error 301. Perquè els usuaris de Pro siguin participants de primera classe, fes que ells també prenguin un bloqueig tou:
| Trigger | Acció |
|---|---|
| OnRecordLoad | Lock_Status per al registre. Estableix $$LOCKED_BY_OTHER i el text de l’avís |
| Botó d’edició (o primera entrada en un camp) | Lock_Acquire mitjançant Perform Script on Server, desa $$TOKEN. En DENIED, queda’t en només lectura i mostra qui el té |
| OnTimer, cada 30 s | Titular: Lock_Renew. En LOST, reverteix les edicions locals i avisa. No titular: Lock_Status per refrescar l’avís |
| Desar, OnRecordRevert, OnLayoutExit, OnWindowClose | Valida si cal, després Lock_Release |
| OnRecordCommit | Una última comprovació de renovació. Si LOST, retorna False per cancel·lar la validació |
Una finestra només té un temporitzador, així que aquest únic guió fa tant el heartbeat com el sondeig, i pot actuar de despatxador si la presentació ja fa servir un temporitzador. Amaga els controls d’edició o bloca l’entrada a camps (OnObjectEnter retornant False quan $$LOCKED_BY_OTHER = 1) i mostra un avís com “Bloquejat per la Maria des de les 10:42”. El sondeig continua afegint fins a un interval de temporitzador de latència.
7. Clients API i WebViewer #
Una crida a guió independent a través de la Data API té aquest aspecte (el paràmetre va codificat en URL a la pràctica, i scriptResult torna com una cadena que has de parsejar una segona vegada):
GET /fmi/data/vLatest/databases/CRM/layouts/Locks_API/script/Lock_Acquire
?script.param={"table":"Invoices","id":"3F2A…","name":"Maria","client":"webviewer","ttl":90}
Els mateixos guions funcionen a través d’OData on la teva versió de servidor els pugui executar. Regles per a una UI de navegador o WebViewer:
- Heartbeat a TTL/3 (30 s per a un TTL de 90 s), i renova també immediatament a
visibilitychange, perquè els navegadors ofeguen els temporitzadors a les pestanyes en segon pla. - Mantén el token en memòria per pestanya. Dues pestanyes del mateix usuari són dues sessions, i la segona pren el control de la primera.
- En descarregar la pàgina, envia un
Lock_Releasede millor esforç ambfetchikeepalive: true.navigator.sendBeaconno pot establir la capçalera Authorization que necessita la Data API, així que fes-lo servir només contra el teu propi backend. El TTL és la xarxa de seguretat en qualsevol cas. - En LOST, congela el formulari, mantén visibles els canvis de l’usuari i ofereix recarregar el registre. Mai no reintentis una escriptura en silenci.
- En BUSY, reintenta amb un backoff curt aleatori.
8. Concurrència optimista #
Com que el tràfic de bloquejos viu a la seva pròpia taula, el modId del registre de dades ara només canvia quan canvien dades reals, així que és una versió fiable. La Data API el retorna amb cada registre, i un PATCH que inclou un modId obsolet falla amb l’error 306. Fes-lo servir de dues maneres:
- PATCH simple en taules sense requisit de bloqueig: envia el
modIdque vas llegir, i en cas de 306 recarrega i reintenta. - Escriptures protegides: envia el
modIdi el token junts.
modId és gruixut: qualsevol canvi al registre, fins i tot d’un camp no relacionat, provoca un conflicte. L’objecte expected a Record_Update_Guarded és l’opció més fina. El client envia els valors originals dels camps que està canviant, i el guió rebutja l’escriptura només si aquests camps concrets han canviat, de manera que les edicions que no se solapen es fusionen.
Dos límits d’abast a documentar a la teva solució: els portals i els registres relacionats tenen el seu propi modId, i un bloqueig sobre un pare no cobreix els seus fills per si sol. Per bloquejar un document complet, fes que la clau de bloqueig sigui el registre arrel, i deixa que els escriptors protegits de les taules filles acceptin el token de l’arrel. No he verificat el comportament d’ETag o If-Match a l’endpoint OData de FileMaker. Si no està disponible, crida el guió protegit en lloc seu.
9. Modes de fallada #
| Situació | Resultat |
|---|---|
| El client s’adorm més enllà del seu TTL, ningú més va tocar el registre | Renovar i escriure segueixen funcionant (el token coincideix). modId protegeix contra dades obsoletes |
| Algú va prendre el control mentrestant | Renovar i les escriptures protegides retornen LOST |
| El client cau | La concessió expira després del TTL i la següent adquisició pren el control |
| El guió de servidor mor a mitja transició | La sessió es tanca, la fila oberta s’allibera i els canvis no validats es descarten |
| FileMaker Server es reinicia | Les files persisteixen i les concessions expiren amb normalitat |
| Usuari de Pro editant nativament | Les escriptures API reben IN_USE fins que l’edició acaba |
| Bloqueig encallat | Lock_Force_Release |
Si la teva versió de FileMaker proporciona els passos Open Transaction i Commit Transaction (introduïts a FileMaker 2024, que jo sàpiga), encaixen bé dins de Record_Update_Guarded per a escriptures multiregistre. Tot i així no poden abastar diverses peticions API, per la qual cosa no reemplacen la concessió.
10. Quan un bloqueig tou no n’hi ha prou #
FileMaker no envia canvis, així que tots els enfocaments anteriors tenen latència de sondeig, i la taula de bloquejos és un punt calent sota molta contenció. Si necessites notificació instantània als usuaris de WebViewer o un alt volum de bloquejos, trasllada la coordinació de bloquejos a un servei extern, per exemple Redis amb SET key value NX PX ttl i pub/sub, o un petit servei davant d’ell. Els guions de FileMaker l’hi criden llavors mitjançant Insert from URL. Les contrapartides són una dependència extra i una segona font de veritat que ha de ser assolible des de cada escriptor. Per a la majoria de solucions, el disseny basat en taula descrit aquí n’hi ha prou.
Conclusió #
Segueixes sense poder activar un bloqueig natiu de FileMaker des de la Data API o OData, però pots aconseguir edició coordinada entre tots els clients amb uns quants ingredients: una taula Locks dedicada, concessions renovades contra el rellotge del servidor, un token de fencing verificat a cada escriptura, transicions de bloqueig fetes atòmiques per un bloqueig natiu mantingut dins del guió, privilegis que obliguen a escriure a través d’aquests guions, i modId com a segona línia de defensa. Construeix-lo com a coordinació, fes-lo complir al servidor i registra-ho tot.