diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..db1dfd1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: test + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + unit: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + - run: npm run docker:start + - run: npm install + - run: npm test \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9e7c0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +output +node_modules +package-lock.json +output_test \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..537f81f --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +ignore-scripts=true \ No newline at end of file diff --git a/README.md b/README.md index 3ec2ae3..e34494c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,22 @@ # open-abap-lock -lock + +Concurrent/cross session locking for Open ABAP. + +Requires and works with PostgreSQL as the database backend, and only on the DEFAULT connection. + +## Notes + +* [PostgreSQL - Advisory Locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS) +* [PostgreSQL - Advisory Lock Functions](https://www.postgresql.org/docs/9.1/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS) + +wildcards? +scope? +release on crash +table `pg_locks` + +## Todo + +* `_scope` +* `wait` flag +* `mode` +* release at commit work if update task diff --git a/abap_transpile.jsonc b/abap_transpile.jsonc new file mode 100644 index 0000000..6fd872b --- /dev/null +++ b/abap_transpile.jsonc @@ -0,0 +1,25 @@ +{ + "input_folder": ["src", "test/src"], + "input_filter": [], + "output_folder": "output", + "write_unit_tests": true, + "write_source_map": true, + "libs": [ + { + "url": "https://github.com/open-abap/open-abap-core" + } + ], + "options": { + "ignoreSyntaxCheck": false, + "addFilenames": true, + "addCommonJS": true, + "populateTables": { + "reposrc": false + }, + "setup": { + "filename": "../test/setup.mjs", + "postFunction": "postFunction", + "preFunction": "preFunction" + } + } +} \ No newline at end of file diff --git a/abaplint.jsonc b/abaplint.jsonc index c216cc9..b0a9a24 100644 --- a/abaplint.jsonc +++ b/abaplint.jsonc @@ -1,6 +1,9 @@ { "global": { - "files": "/src/**/*.*" + "files": [ + "/src/**/*.*", + "/test/src/**/*.*" + ] }, "dependencies": [ { @@ -130,7 +133,7 @@ "exportDynpro": true, "dynamicSQL": true }, - "db_operation_in_loop": true, + "db_operation_in_loop": false, "definitions_top": true, "description_empty": false, "double_space": { @@ -308,25 +311,9 @@ "checkForms": true }, "method_overwrites_builtin": true, - "method_parameter_names": { - "exclude": [], - "severity": "Error", - "patternKind": "required", - "ignoreNames": [], - "ignorePatterns": [], - "ignoreExceptions": true, - "importing": "^I._.+$", - "returning": "^R._.+$", - "changing": "^C._.+$", - "exporting": "^E._.+$" - }, + "method_parameter_names": false, "mix_returning": true, - "modify_only_own_db_tables": { - "exclude": [], - "severity": "Error", - "reportDynamic": true, - "ownTables": "^[yz]" - }, + "modify_only_own_db_tables": false, "msag_consistency": { "exclude": [], "severity": "Error", @@ -548,7 +535,7 @@ "unused_types": false, "unused_variables": false, "use_bool_expression": true, - "use_class_based_exceptions": true, + "use_class_based_exceptions": false, "use_line_exists": true, "use_new": true, "when_others_last": true, diff --git a/package.json b/package.json new file mode 100644 index 0000000..554afbf --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "open-abap-lock", + "version": "1.0.0", + "private": true, + "description": "test", + "scripts": { + "lint": "abaplint", + "unit": "rm -rf output && abap_transpile abap_transpile.jsonc && echo RUNNING && node output/index.mjs", + "docker:start": "docker compose -p open-abap-lock -f test/stack.yml up -d", + "docker:stop": "docker compose -p open-abap-lock -f test/stack.yml down -v", + "test": "npm run lint && npm run unit" + }, + "license": "", + "dependencies": { + "@abaplint/cli": "^2.114.6", + "@abaplint/runtime": "^2.12.16", + "@abaplint/database-pg": "^2.11.78", + "@abaplint/transpiler-cli": "^2.12.16" + } +} diff --git a/src/kernel_lock_concurrent.clas.abap b/src/kernel_lock_concurrent.clas.abap index f58b01f..e3199be 100644 --- a/src/kernel_lock_concurrent.clas.abap +++ b/src/kernel_lock_concurrent.clas.abap @@ -1,7 +1,131 @@ CLASS kernel_lock_concurrent DEFINITION PUBLIC. PUBLIC SECTION. + CLASS-METHODS class_constructor. + + CLASS-METHODS enqueue + IMPORTING + input TYPE any + table_name TYPE string + enqueue_name TYPE string + EXCEPTIONS + foreign_lock + system_failure. + + CLASS-METHODS dequeue + IMPORTING + table_name TYPE string + enqueue_name TYPE string + input TYPE any. + + TYPES: BEGIN OF ty_cleanup, + valid_locks TYPE i, + cleaned_locks TYPE i, + END OF ty_cleanup. + CLASS-METHODS cleanup_locks + RETURNING + VALUE(rs_result) TYPE ty_cleanup. + PRIVATE SECTION. + CLASS-METHODS build_lock_key + IMPORTING + input TYPE any + table_name TYPE string + RETURNING + VALUE(rv_lock_key) TYPE kernel_locks-lock_key. ENDCLASS. CLASS kernel_lock_concurrent IMPLEMENTATION. + METHOD class_constructor. + cleanup_locks( ). + ENDMETHOD. + + METHOD cleanup_locks. + SELECT * FROM kernel_locks INTO TABLE @DATA(lt_locks) ORDER BY PRIMARY KEY ##SUBRC_OK. + LOOP AT lt_locks INTO DATA(ls_lock). + DATA(lv_exists) = lcl_advisory=>exists( lcl_key=>encode( ls_lock-lock_key ) ). + IF lv_exists = abap_true. + rs_result-valid_locks = rs_result-valid_locks + 1. + ELSE. + DELETE FROM kernel_locks WHERE table_name = @ls_lock-table_name AND lock_key = @ls_lock-lock_key. + rs_result-cleaned_locks = rs_result-cleaned_locks + 1. + ENDIF. + ENDLOOP. + ENDMETHOD. + + METHOD build_lock_key. + + DATA lr_dref TYPE REF TO data. + DATA lo_structdescr TYPE REF TO cl_abap_structdescr. + DATA lv_string TYPE string. + + FIELD-SYMBOLS TYPE any. + + CREATE DATA lr_dref TYPE (table_name). + ASSIGN lr_dref->* TO . + + lo_structdescr ?= cl_abap_typedescr=>describe_by_data( ). + ASSERT lo_structdescr IS NOT INITIAL. + + LOOP AT lo_structdescr->components INTO DATA(ls_component). + WRITE '@KERNEL lv_string.set(input[ls_component.get().name.get().toLowerCase().trimEnd()] || "");'. + + ASSIGN COMPONENT ls_component-name OF STRUCTURE TO FIELD-SYMBOL(). + ASSERT sy-subrc = 0. + = lv_string. + ENDLOOP. + + rv_lock_key = . + + ENDMETHOD. + + METHOD enqueue. + + DATA ls_lock_row TYPE kernel_locks. + +******************* + + DATA(lv_lock_key) = build_lock_key( + input = input + table_name = table_name ). + ASSERT lv_lock_key IS NOT INITIAL. + + ls_lock_row-table_name = table_name. + ls_lock_row-lock_key = lv_lock_key. + ls_lock_row-username = sy-uname. + GET TIME STAMP FIELD ls_lock_row-timestamp. + ls_lock_row-hostname = sy-host. + ls_lock_row-lock_mode = ''. + ls_lock_row-lock_name = enqueue_name. + + TRY. + lcl_advisory=>lock( lcl_key=>encode( ls_lock_row-lock_key ) ). + CATCH lcx_advisory_lock_failed. + RAISE foreign_lock. + ENDTRY. + + INSERT kernel_locks FROM @ls_lock_row. + ASSERT sy-subrc = 0. + + ENDMETHOD. + + METHOD dequeue. + + DATA(lv_lock_key) = build_lock_key( + input = input + table_name = table_name ). + + TRY. + lcl_advisory=>lock( lcl_key=>encode( lv_lock_key ) ). + CATCH lcx_advisory_lock_failed. + " it doesnt have the lock, or another session has the lock + RETURN. + ENDTRY. + + DELETE FROM kernel_locks WHERE table_name = table_name AND lock_key = lv_lock_key. + + " advisory locks stack, + lcl_advisory=>unlock( lcl_key=>encode( lv_lock_key ) ). + lcl_advisory=>unlock( lcl_key=>encode( lv_lock_key ) ). + ENDMETHOD. + ENDCLASS. \ No newline at end of file diff --git a/src/kernel_lock_concurrent.clas.locals.abap b/src/kernel_lock_concurrent.clas.locals.abap new file mode 100644 index 0000000..9d1d171 --- /dev/null +++ b/src/kernel_lock_concurrent.clas.locals.abap @@ -0,0 +1,129 @@ +CLASS lcl_key DEFINITION. + PUBLIC SECTION. + CLASS-METHODS encode + IMPORTING + iv_text TYPE kernel_locks-lock_key + RETURNING + VALUE(rv_key) TYPE int8. +ENDCLASS. + +CLASS lcl_key IMPLEMENTATION. + METHOD encode. + DATA lv_hash TYPE xstring. + DATA lv_empty TYPE xstring. + +* todo: rework this sometime? there might be collissions + TRY. + cl_abap_hmac=>calculate_hmac_for_raw( + EXPORTING + if_algorithm = 'MD5' + if_key = lv_empty + if_data = cl_abap_conv_codepage=>create_out( )->convert( |{ iv_text }| ) + IMPORTING + ef_hmacxstring = lv_hash ). + CATCH cx_abap_message_digest. + ASSERT 1 = 2. + ENDTRY. + + rv_key = lv_hash(8). + ENDMETHOD. + +ENDCLASS. + +****************************************************************** + +CLASS lcx_advisory_lock_failed DEFINITION INHERITING FROM cx_static_check. +ENDCLASS. + +CLASS lcx_advisory_lock_failed IMPLEMENTATION. +ENDCLASS. + +****************************************************************** + +CLASS lcl_advisory DEFINITION. + PUBLIC SECTION. + CLASS-METHODS lock + IMPORTING + key TYPE int8 + RAISING + lcx_advisory_lock_failed. + + CLASS-METHODS unlock + IMPORTING + key TYPE int8. + + "! Check if the lock exists, no information if its the current session holding it, or which is holding it + CLASS-METHODS exists + IMPORTING + key TYPE int8 + RETURNING + VALUE(rv_exists) TYPE abap_bool. + PRIVATE SECTION. +ENDCLASS. + +CLASS lcl_advisory IMPLEMENTATION. + + METHOD exists. +* https://www.postgresql.org/docs/current/view-pg-locks.html + + DATA lv_str TYPE string. + DATA lr_foo TYPE REF TO data. + + + ASSERT key IS NOT INITIAL. + GET REFERENCE OF lv_str INTO lr_foo. + + TRY. + DATA(lo_result) = NEW cl_sql_statement( )->execute_query( + |SELECT EXISTS(SELECT * FROM pg_locks WHERE (classid::bigint << 32) \| objid::bigint = { key })| ). + lo_result->set_param( lr_foo ). + lo_result->next( ). + lo_result->close( ). + CATCH cx_sql_exception INTO DATA(lx_sql). + WRITE / 'SQL Error:'. + WRITE / lx_sql->get_text( ). + ASSERT 1 = 2. + ENDTRY. + + rv_exists = lv_str. + ENDMETHOD. + + METHOD lock. + + DATA lv_str TYPE string. + DATA lr_foo TYPE REF TO data. + + + ASSERT key IS NOT INITIAL. + GET REFERENCE OF lv_str INTO lr_foo. + + TRY. + DATA(lo_result) = NEW cl_sql_statement( )->execute_query( |SELECT pg_try_advisory_lock( { key } )| ). + lo_result->set_param( lr_foo ). + lo_result->next( ). + lo_result->close( ). + CATCH cx_sql_exception INTO DATA(lx_sql). + WRITE / 'SQL Error:'. + WRITE / lx_sql->get_text( ). + ASSERT 1 = 2. + ENDTRY. + + IF lv_str <> abap_true. + RAISE EXCEPTION TYPE lcx_advisory_lock_failed. + ENDIF. + + ENDMETHOD. + + METHOD unlock. + + TRY. + NEW cl_sql_statement( )->execute_query( |SELECT pg_advisory_unlock( { key } )| ). + CATCH cx_sql_exception INTO DATA(lx_sql). + WRITE / 'SQL Error:'. + WRITE / lx_sql->get_text( ). + ASSERT 1 = 2. + ENDTRY. + + ENDMETHOD. + +ENDCLASS. \ No newline at end of file diff --git a/src/kernel_lock_concurrent.clas.testclasses.abap b/src/kernel_lock_concurrent.clas.testclasses.abap new file mode 100644 index 0000000..418b4a1 --- /dev/null +++ b/src/kernel_lock_concurrent.clas.testclasses.abap @@ -0,0 +1,17 @@ +CLASS ltcl_test_key DEFINITION FOR TESTING RISK LEVEL HARMLESS DURATION SHORT FINAL. + PUBLIC SECTION. + METHODS test1 FOR TESTING RAISING cx_static_check. +ENDCLASS. + +CLASS ltcl_test_key IMPLEMENTATION. + METHOD test1. + DATA(lv_input) = |abc|. + DATA(lv_key) = lcl_key=>encode( lv_input ). + cl_abap_unit_assert=>assert_not_initial( lv_key ). + + cl_abap_unit_assert=>assert_equals( + act = lv_key + exp = -8070080442485551184 ). + ENDMETHOD. + +ENDCLASS. \ No newline at end of file diff --git a/src/kernel_locks.tabl.xml b/src/kernel_locks.tabl.xml new file mode 100644 index 0000000..f3e12cb --- /dev/null +++ b/src/kernel_locks.tabl.xml @@ -0,0 +1,81 @@ + + + + + + KERNEL_LOCKS + E + TRANSP + Locks + A + 1 + + + KERNEL_LOCKS + A + 0 + APPL0 + N + + + + TABLE_NAME + X + 0 + C + 000060 + X + CHAR + 000030 + CHAR + + + LOCK_KEY + X + TEXT255 + 0 + X + E + + + USERNAME + SYUNAME + 0 + E + + + TIMESTAMP + TIMESTAMPL + 0 + E + + + HOSTNAME + 0 + g + 000008 + STRG + STRG + + + LOCK_MODE + 0 + C + 000002 + CHAR + 000001 + CHAR + + + LOCK_NAME + 0 + C + 000032 + CHAR + 000016 + CHAR + + + + + diff --git a/test/setup.mjs b/test/setup.mjs new file mode 100644 index 0000000..9a86ff3 --- /dev/null +++ b/test/setup.mjs @@ -0,0 +1,29 @@ +import {PostgresDatabaseClient} from "@abaplint/database-pg"; + +export async function preFunction(abap, schemas, insert) { + abap.context.databaseConnections["DEFAULT"] = new PostgresDatabaseClient({ + trace: false, + user: "postgres", + host: "localhost", + database: "postgres", + password: "postgres", + port: 5432, + }); + await abap.context.databaseConnections["DEFAULT"].connect(); + + for (let i = 0; i < schemas.pg.length; i++) { + const element = schemas.pg[i]; + schemas.pg[i] = element.replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS "); + } + await abap.context.databaseConnections["DEFAULT"].execute(schemas.pg); + + for (let i = 0; i < insert.length; i++) { + const element = insert[i]; + insert[i] = element.replace(/;$/, "") + " ON CONFLICT DO NOTHING;"; + } + await abap.context.databaseConnections["DEFAULT"].execute(insert); +} + +export async function postFunction() { + abap.Classes["KERNEL_LOCK"] = abap.Classes["KERNEL_LOCK_CONCURRENT"]; +} \ No newline at end of file diff --git a/test/src/cl_test_concurrent_locks.clas.abap b/test/src/cl_test_concurrent_locks.clas.abap new file mode 100644 index 0000000..300c090 --- /dev/null +++ b/test/src/cl_test_concurrent_locks.clas.abap @@ -0,0 +1,7 @@ +CLASS cl_test_concurrent_locks DEFINITION PUBLIC. + PUBLIC SECTION. +ENDCLASS. + +CLASS cl_test_concurrent_locks IMPLEMENTATION. + +ENDCLASS. \ No newline at end of file diff --git a/test/src/cl_test_concurrent_locks.clas.testclasses.abap b/test/src/cl_test_concurrent_locks.clas.testclasses.abap new file mode 100644 index 0000000..8741c1c --- /dev/null +++ b/test/src/cl_test_concurrent_locks.clas.testclasses.abap @@ -0,0 +1,66 @@ +CLASS ltcl_test DEFINITION FOR TESTING RISK LEVEL HARMLESS DURATION MEDIUM FINAL. + PUBLIC SECTION. + METHODS test_enqueue_dequeue FOR TESTING RAISING cx_static_check. + METHODS standalone_dequeue FOR TESTING RAISING cx_static_check. + METHODS cleanup_valid FOR TESTING RAISING cx_static_check. +ENDCLASS. + +CLASS ltcl_test IMPLEMENTATION. + + METHOD test_enqueue_dequeue. + + CALL FUNCTION 'ENQUEUE_EZABAPGIT_UNIT_T' + EXPORTING + bname = 'HELLO' + EXCEPTIONS + foreign_lock = 1 + system_failure = 2 + OTHERS = 3. + cl_abap_unit_assert=>assert_subrc( ). + + " todo: test that lock is held? + + CALL FUNCTION 'DEQUEUE_EZABAPGIT_UNIT_T' + EXPORTING + bname = 'HELLO'. + + ENDMETHOD. + + METHOD standalone_dequeue. + + " should not raise error even if no lock is held + CALL FUNCTION 'DEQUEUE_EZABAPGIT_UNIT_T' + EXPORTING + bname = 'NONEXISTENT_LOCK'. + + ENDMETHOD. + + METHOD cleanup_valid. + +* start with a clean state + kernel_lock_concurrent=>cleanup_locks( ). + + CALL FUNCTION 'ENQUEUE_EZABAPGIT_UNIT_T' + EXPORTING + bname = 'HELLO' + EXCEPTIONS + foreign_lock = 1 + system_failure = 2 + OTHERS = 3. + cl_abap_unit_assert=>assert_subrc( ). + + DATA(ls_result) = kernel_lock_concurrent=>cleanup_locks( ). + cl_abap_unit_assert=>assert_equals( + act = ls_result-valid_locks + exp = 1 ). + cl_abap_unit_assert=>assert_equals( + act = ls_result-cleaned_locks + exp = 0 ). + + CALL FUNCTION 'DEQUEUE_EZABAPGIT_UNIT_T' + EXPORTING + bname = 'HELLO'. + + ENDMETHOD. + +ENDCLASS. \ No newline at end of file diff --git a/test/src/ezabapgit_unit_t.enqu.xml b/test/src/ezabapgit_unit_t.enqu.xml new file mode 100644 index 0000000..b41272d --- /dev/null +++ b/test/src/ezabapgit_unit_t.enqu.xml @@ -0,0 +1,44 @@ + + + + + + EZABAPGIT_UNIT_T + E + E + ZABAPGIT_UNIT_TE + Test + + + + EZABAPGIT_UNIT_T + ZABAPGIT_UNIT_TE + 0001 + ZABAPGIT_UNIT_TE + E + + + + + EZABAPGIT_UNIT_T + 0001 + MANDT + ZABAPGIT_UNIT_TE + MANDT + X + E + + + EZABAPGIT_UNIT_T + 0002 + BNAME + ZABAPGIT_UNIT_TE + BNAME + X + E + XUS + + + + + diff --git a/test/src/zabapgit_unit_te.tabl.xml b/test/src/zabapgit_unit_te.tabl.xml new file mode 100644 index 0000000..0b25ca6 --- /dev/null +++ b/test/src/zabapgit_unit_te.tabl.xml @@ -0,0 +1,47 @@ + + + + + + ZABAPGIT_UNIT_TE + E + TRANSP + X + Test + A + 1 + + + ZABAPGIT_UNIT_TE + A + 0 + APPL0 + N + + + + MANDT + X + MANDT + 0 + X + E + + + BNAME + X + SYUNAME + 0 + X + E + + + FIELD + SYUNAME + 0 + E + + + + + diff --git a/test/stack.yml b/test/stack.yml new file mode 100644 index 0000000..48dd77c --- /dev/null +++ b/test/stack.yml @@ -0,0 +1,10 @@ +services: + postgresql: + image: postgres:alpine + restart: always + environment: + POSTGRES_PASSWORD: postgres + ports: + - '5432:5432' + tmpfs: + - /var/lib/postgresql/data \ No newline at end of file